Before we kick of a short explanation about notations used in during the course. In code examples you will see:
$ echo 'Hello world'
Hello world
>>> print ('Hello universe')
Hello universe
class Hello:
def world(self):
print('Allo allo')
We will learn today to cook with objects in Python 3.6. We will start with creating a list of all objects of a pizzeria.
Because with objects oriented programming you want your code to work like a pizzeria.
- Waiter: You talk to the waiter. He takes your order, serves the food, checks if you want anything else and eventually you can pay the bill.
- Kitchen: In the kitchen the food is prepared.
- Dish: A dish is a collection of ingredients prepared in a certain order.
- Menu: A collection of recipes with available ingredients
- Ingredients: Part of a recipe.
- Stock: Amount of ingredients available
- Storageroom: Holds the stock
As explained on first impression the differences between Python 2 and 3 look minimal. So which version to choose?
Python 3 was released already back in 2008. This was a big thing since your Python 2 code would not just run in Python 3 out of the box. And think not only of your own codebase, but all the other dependencies that you have on third party libraries! Are they updated already? You can imagine the pain of this for the Python community at large.
But! Even though it is a pain now, that is not an argument to postpone and have lot's more pain later. So
by Python's benevolent dictator for life it was so decreed to bite the bullet. Amongst things print became a
function instead of a language statement (like assert), strings became unicode instead of ASCII and
having to explicitly define a unicode string with u"I'm a unicode string"
and the standard library was refactored:
naming was normalized and parts of the library where restructured.
It is now 2017. All libraries that you would use have been ported to Python 3. Hooray! For this project we will use Python 3.6 (the latest major release).
Lets start by setting up the project:
- open your terminal
- create a new directory
- go into the directory
- create a .gitignore file with the following setup => https://github.com/github/gitignore/blob/master/Python.gitignore
- initialize a new git repository:
$ git init
$ git add .
$ git commit -m "Initial commit"
[00] As a developer. I want to have each project that I work on run in an isolated environment, have a user friendly REPL environment and manage dependencies to third party projects easily.
Here we are going to lay the ground work for our project and make sure that the basic tools we are going to use are operating 'within normal parameters'.
There is a couple of tools that we will use in almost all Python projects:
This used to be a separate project, but is now part of Python itself. Virtualenv creates isolated Python environments. Within these environments you can safely install or remove packages without affecting any of your other projects.
Pip is Python's package manager. Pip allows you to easily install third party libraries within your project's isolated virtualenv. Dependencies can be specified in a separate file (or multiple files) that you can manage within your source repository. Also (especially important for production environments) you can 'version pin' third party dependencies. This ensures that you only work with versions that are proven to work correctly in all of your deployment environments.
Python's shell is a so called REPL environment: a read-eval-print-loop. An interactive program that runs the code you enter line by line and evaluates the code after every line, printing any output if required. For the purpose of this course this allows us to quickly try out code.
IPython is an extension of the Python shell that adds things like tab-completion and reloading.
This is a so called linter. It does two things:
1. It checks the syntax of your code to see whether you made any errors
2. It checks the formatting of your code if it adheres to Python's PEP8 styleguide
Tasks:
- create a virtual environment for our project
- add a specific version of IPython as a dependency of our project
- install dependencies using pip
- test if it all works
- Run the following command in your shell:
$ python -m venv .venv
$ source .venv/bin/activate
Your shell prompt should now look something like: (.venv) >
Note: The last line activates the virtual environment, this means that if you type python
you will run the Python
command that is isolated to this virtual environment. You will have to activate the virtual env in every new shell that
you open with this same command. If you don't do that running the python
command will use the system installed Python.
There are tools to automate this, but that is outside of the scope of this course.
- Create a requirements.txt file in your project folder, this file should contain the following:
ipython==5.3.0
flake8==3.3.0
Note: This pins IPython version 5.3.0 and flake8 version 3.3.0 as a dependency to our project. Even if newer versions are released of these projects, when we reinstall the dependencies we will install these versions until we explicitly upgrade decide to upgrade them.
- In your shell run the following command:
$ pip install -r requirements.txt
This will instruct pip to install the dependencies specified in your requirements.txt
. Besides the dependency that
we have explicitly specified you will also see that pip installs packages like pygments
amongst others. These are
dependencies that the IPython project needs in order to work correctly.
Note: When working with dependencies always only explicitly declare the dependencies and versions of them that you need. Leave it up to pip to resolve all other dependencies.
- In your terminal start IPython:
$ ipython
You should now see something like:
Python 3.6.0 (default, Dec 24 2016, 00:01:50)
Type "copyright", "credits" or "license" for more information.
IPython 5.3.0 -- An enhanced Interactive Python.
? -> Introduction and overview of IPython's features.
%quickref -> Quick reference.
help -> Python's own help system.
object? -> Details about 'object', use 'object??' for extra details.
In [1]:
You should see the above text and be able to run Python commands. Type: print('Hello world')
and press enter to execute
the first (obligatory Hello World) Python code of your project. To exit press CTRL-D
Also test that flake8
works:
$ flake8 --max-line-length=100
Since there are currently no Python files, at this moment flake8 outputs nothing. But while you are working on your
code it is good to run flake8
in between. A lot of editors and IDE's automate all of this, so you don't have to
remember to run these tools manually. We instruct flake8 to allow a line length of 100, by default PEP8 (the Python
styleguide) only allows 79 characters per line, which is a bit arcane for modern computers.
Allright! We are ready to get going with the actual project!
[01] As a guest. I want to be greeted by the waiter when I enter the pizzeria. So that I feel like I am welcome.
Tasks:
- create a waiter
- add a method to the waiter to greet a guest
- create a pizzeria
- import the waiter in the pizzeria
- make an instance of the waiter in the pizzeria
- call the greeting method
- test if it all works
- save your progress
- Create a file
waiter.py
- Create a class in this new file. We create a class like:
class Waiter:
pass
A thing to note here is the statement pass
. Python defines its scope using indentation.
PEP8: https://www.python.org/dev/peps/pep-0008/ which is the Python style guide defines indentation
as 4 spaces. To say that a scope doesn't have any content yet pass
is required. This would be equivalent
to public class Waiter{}
in Java.
In Python 2 code you will always see classes defined as: class Waiter(object)
. Very old versions of Python
had something dubbed old-style classes. These classes did not have object
as the root class. Don't ask! ;)
Python 3 completely removed that so class Waiter
now inherits from object
. Python does multiple inheritance
if you want to for example define a RobotWaiter, that is a Robot which is also a Waiter you can write a class
definition like:
class Robot:
pass
class RobotWaiter(Robot, Waiter):
pass
This is of course quite a simple class hierarchy, having multiple classes inheriting from one another can quickly turn into something much more complex. What code will be run when? Python defines an explicit method resolution order (MRO) based on an algorithm called C3. For our RobotWaiter class the MRO is:
>>> RobotWaiter.__mro__
(<class '__main__.RobotWaiter'>, <class '__main__.Robot'>, <class '__main__.Waiter'>, <class 'object'>)
The class RobotWaiter
will first
- add a method
greet_guest
- the method should print a greeting
class Waiter:
def greet_guest(self):
print("Good day. Welcome to our lovely little restaurant.")
- create a file
__init__.py
. The init.py files are required to make Python treat the directories as containing packages, and enable you to import modules inside a directory; - create a file
pizzeria.py
- at the top of
pizzeria.py
addfrom waiter import Waiter
to include the Waiter class.
An instance is when we create an object from a class. You can create an object by calling the constructor method, Waiter()
, and by default it takes no arguments.
- Create a new instance of
Waiter
and assign it to a variablew = Waiter()
- Call a method on an object by placing it after a dot. Like
object.method()
. So to call thegreet_guest
method on the object stored inw
you can callw.greet_guest()
- Open the terminal
- Run the pizzeria class with python
ipython pizzeria.py
- Should display:
"Good day. Welcome to our lovely little restaurant"
- Open the terminal
- Now is a good time to run
flake8
and see whether you adhere to Python's style guide. - Add all the files in your project to git
git add .
- Commit the files to your local git
git commit -m 'Added greetings'
Tasks:
- Create a method for the waiter to serve_guests.
- The serve_guests method should print a list of options for the guests
- A guest should be able to choose from the options by their number.
- After a choice is made, the waiter can take action
- Call the serve_guest method from the pizzeria
- Test if it all works
- save your progress
def serve_guest(self):
Add a small menu to the body of the serve_guest()
method. For example
def serve_guest(self):
print("How can I be of service?")
print("1. Would you like to order a pizza?")
print("2. Would you like to leave?")
Call the input
method at the bottom of the serve_guest method
choice = input()
- create a method to handle the input of the
input()
method. When we create a method that needs input from another method, you have to add this data to its arguments list.
def take_order(self, order_number):
- call the
take_order()
method using your choice variable. By default,input()
parses input params as strings, so be sure to convert the choice to an integer usingint()
.
self.take_order(order_number=int(choice))
- if the choice is 1 or 2 or none of these, give appropriate response. To easily do this we can use an if/elif/else statement. Add the following content to the take_order() function.
def take_order(self, order_number):
if order_number == 1:
print("Let me get the menu")
elif order_number == 2:
print("Thank you for your visit!")
else:
print("I really don't understand")
- on the waiter object stored in the
w
variable, call theserve_guest()
method.
w.serve_guest()
- Open the terminal
- Run the pizzeria class with python
ipython pizzeria.py
- Should display:
"Good day. Welcome to our lovely little restaurant"`
"How can I be of service?"
"1. Would you like to order a pizza?"
"2. Would you like to leave?"
- Wait for input
- Exit
- Open the terminal
- Add all the files in your project to git
git add .
- Commit the files to your local git
git commit -m 'choices'
A menu is a combination of dishes.
Tasks:
- create a class for menu
- create a class for dish
- add an initialize method to the menu to create an array of dishes
- add an initialize method to the dish
- create an array of dishes calling its constructor with a name
- let the waiter know about the menu
- let the waiter tell guests the contents of the menu
- test your progress
- Improve the display of dishes
- test your progress
- Save your progress
- Create a file
menu.py
- Create a class in this new file
class Menu:
###2. create a class for dish
- Create a file
dish.py
- Create a class in this new file
class Dish:
###3. add an initialize method to the menu to create an array of dishes
When an object is constructed, its initialize method is called. So when you call Menu() to create an object of the menu class, it searches for the initialize method in the Menu class.
- Add the initialize method to the Menu class
def __init__(self):
- Inside the initialize method of Menu we are going create the following dishes. Margherita, Napoletana, Peperoni
###4. Add an initialize method to the dish
- To create a
Dish
with a name, you can use the initialize method again. Add an initialize method (constructor) to theDish
class that takes adish_name
as an argument.
def __init__(self, dish_name):
- Assign the argument of the constructor to an instance variable
self.name
def __init__(self, dish_name)
self.name = dish_name
An instance variable is available to all methods of a class instance (object). Unlike a normal variable which is only available inside the method where it was created.
###5. Create an array of dishes calling its constructor with a name
- import the file containing the dish class inside the file containing the menu class
from dish import Dish
- create an array inside the initialize method of the Menu class and assign it to an instance variable
self.menu = []
- create instances of the dish class and add them to the variable holding the array
self.menu.append(Dish(dish_name="Margherita"))
self.menu.append(Dish(dish_name="Napoletana"))
self.menu.append(Dish(dish_name="Pepperoni"))
###6. let the waiter know about the menu
- import the file containing the menu in the pizzeria file.
from menu import Menu
- Create an instance of the menu class by calling its constructor.
menu = Menu()
- Add a constructor method to the waiter class that takes an argument for menu.
- Assign the argument to an instance variable.
def __init__(self, menu):
self.menu = menu
- Change the call to the constructor of Waiter in Pizzeria to receive a menu.
w = Waiter(menu=menu)
###7. let the waiter tell guests the contents of the menu
- define a method in the
Menu
class to return the value of the instance variable.
def contents(self):
return self.menu
- create a
list_menu
method in theWaiter
class to list all the dishes
def list_menu(self):
for dish in self.menu.contents():
print(dish)
- call the
list_menu
method in the if statement
def take_order(self, order_number):
if order_number == 1:
answer = "Let me get the menu"
self.list_menu()
###8. test your progress
- Open the terminal
- Run the pizzeria class with python
ipython pizzeria.py
- Should display:
"Good day. Welcome to our lovely restaurant"
"How can I be of service?"
"1. Would you like to order a pizza?"
"2. Would you like to leave?"
- Choose 1
- Should display:
<dish.Dish object at 0x10f6f7630>
<dish.Dish object at 0x10f724438>
<dish.Dish object at 0x10f724630>
###9. Improve the display of dishes
- In the loop printing the dishes, call the
name
property to display the name
def list_menu(self):
for dish in self.menu.contents():
print(dish.name)
###10. test your program
- Open the terminal
- Run the pizzeria class with python
ipython pizzeria.py
- Should display:
"Good day. Welcome to our lovely little restaurant"
"How can I be of service?"
"1. Would you like to order a pizza?"
"2. Would you like to leave?"
- Choose 1
- Should display:
"Margherita"
"Napoletana"
"Pepperoni"
- Open the terminal
- Add all the files in your project to git
git add .
- Commit the files to your local git
git commit -m 'choices'
[04] As a waiter. I would like to be able to order an item from the kitchen. So that the guests stop bothering me
Tasks:
- add a number to a dish
- allow the ordering of food by this number
- call the order food after the menu was listed and supply the number of the dish
- add a kitchen class
- add a method to kitchen so it can receive orders of dishes
- let the waiter know about the kitchen
- let the kitchen know about the order
- test your progress
- save your progress
def list_menu(self):
for index, dish in enumerate(self.menu.contents()):
print(index, dish.name)
def order_food(self, choice):
chosen_dish = self.menu.contents()[choice]
this method will assign a dish object to the dish variable.
print("Let me get the menu")
self.list_menu()
self.order_food(choice=int(input()))
- Create a file
kitchen.py
- Create a class in this new file
class Kitchen:
def order(self, dish):
print("KITCHEN: Order received for {0}".format(dish.name))
- In the
pizzeria.py
file add an import for the file containing the kitchen - Create a new instance of the
Kitchen
class and assign it to a variable
kitchen = Kitchen()
- Add the option to
Waiter
to receive a kitchen in its initializer method. Assign this argument to an instance variable
def __init__(self, menu, kitchen):
self.menu = menu
self.kitchen = kitchen
- Add the variable kitchen to the initialization of the
Waiter
class.
w = Waiter(menu=menu, kitchen=kitchen)
- In the
order_food
method of the Waiter class add a call to the order method of the Kitchen
def order_food(self, choice):
chosen_dish = self.menu.contents()[choice]
self.kitchen.order(dish=chosen_dish)
- Open the terminal
- Run the pizzeria class with python
ipython pizzeria.py
- Should display:
"Good day. Welcome to our lovely little restaurant"
"How can I be of service?"
"1. Would you like to order a pizza?"
"2. Would you like to leave?"
- Choose 1
- Should display:
"0 Margherita"
"1 Napoletana"
"2 Peperoni"
- Choose 2
- Should display
"KITCHEN: Order received for Peperoni"
- Open the terminal
- Add all the files in your project to git
git add .
- Commit the files to your local git
git commit -m 'choices'
[05] As a cook. I would like to know which ingredients I have to use and which amounts. So that the dishes taste nice
Tasks:
- create a class for Ingredient
- ingredients can have a name and amount
- add constants for the ingredient names
- When creating a dish, add ingredients
- print a list of ingredients and their amount
- let the waiter know about the kitchen
- let the kitchen know about the order
- test your progress
- save your progress
- Create a file
ingredient.py
- Create a class in this new file
def __init__(self, name, amount):
self.name = name
self.amount = amount
class Ingredient:
TOMATO = "Tomato"
DOUGH = "Dough"
MOZZARELLA = "Mozzarella"
ANCHOVIES = "Anchovies"
PEPPERONI = "Peperoni"
Constants are used to prevent misspelling.
- Add an ingredients list to the initializer of the Dish class
def __init__(self, dish_name, ingredients):
self.name = dish_name
self.ingredients = ingredients
- In the menu, where the dishes are initialized, import Ingredient
from ingredient import Ingredient
- ...and add ingredients
self.menu.append(Dish(
dish_name="Margherita",
ingredients=[
Ingredient(name=Ingredient.TOMATO, amount=3),
Ingredient(name=Ingredient.DOUGH, amount=0.25),
Ingredient(name=Ingredient.MOZZARELLA, amount=0.2)
])
)
self.menu.append(Dish(
dish_name="Napoletana",
ingredients=[
Ingredient(name=Ingredient.TOMATO, amount=3),
Ingredient(name=Ingredient.DOUGH, amount=0.25),
Ingredient(name=Ingredient.MOZZARELLA, amount=0.2),
Ingredient(name=Ingredient.ANCHOVIES, amount=0.05)
])
)
self.menu.append(Dish(
dish_name="Pepperoni",
ingredients=[
Ingredient(name=Ingredient.TOMATO, amount=3),
Ingredient(name=Ingredient.DOUGH, amount=0.25),
Ingredient(name=Ingredient.MOZZARELLA, amount=0.2),
Ingredient(name=Ingredient.PEPPERONI, amount=0.1)
])
)
- Improve the order method of the Kitchen class to print a list of ingredients for the dish
def order(self, dish):
print("KITCHEN: Order received for {0}".format(dish.name))
print("I'm gonna need some:")
for ingredient in dish.ingredients:
print("{0} - {1}".format(ingredient.amount, ingredient.name))
- Open the terminal
- Run the pizzeria class with python
ipython pizzeria.py
- Should display:
"Good day. Welcome to our lovely little restaurant"
"How can I be of service?"
"1. Would you like to order a pizza?"
"2. Would you like to leave?"
- Choose 1
- Should display:
"0 Margherita"
"1 Napoletana"
"2 Peperoni"
- Choose 1
- Should display
"KITCHEN: Order received for Napoletana"
"Im gonna need some:"
"3 - Tomato"
"0.25 - Dough"
"0.2 - Mozzarella"
"0.05 - Anchovies"
- Open the terminal
- Add all the files in your project to git
git add .
- Commit the files to your local git
git commit -m 'choices'
[06] As a waiter. I would like to know if a dish can still be ordered. So that I don't try sell unavailable dishes.
Add a storage and check before an order can be placed. When a dish is prepared, update the stock.
- Create a file
storage.py
- Create a class in this new file
from ingredient import Ingredient
def __init__(self):
self.items = [
Ingredient(name=Ingredient.TOMATO, amount=8),
Ingredient(name=Ingredient.DOUGH, amount=2),
Ingredient(name=Ingredient.MOZZARELLA, amount=1),
Ingredient(name=Ingredient.PEPPERONI, amount=0.3),
]
def use(self, amount):
self.amount -= amount
- Create a check_storage method that takes an ingredient as an argument and finds it for you in the storage
def check_storage(self, ingredient):
for storage_item in self.items:
if ingredient.name == storage_item.name:
return storage_item
- Create a fetch method that takes a list of ingredients as an argument and checks them out of the storage using the check_storage method
def fetch(self, ingredients):
for ingredient in ingredients:
storage_item = self.check_storage(ingredient)
if storage_item:
storage_item.use(amount=ingredient.amount)
- import the file which is declaring the storage class in kitchen
from storage import Storage
class Kitchen:
- add an initializer method to the kitchen and create an instance of storage
def __init__(self):
self.storage = Storage()
5. Call the fetch method when an order is placed. Add it to the bottom of the order method of Kitchen
return self.storage.fetch(ingredients=dish.ingredients)
def order_food(self, choice):
chosen_dish = self.menu.contents()[choice]
if self.kitchen.order(dish=chosen_dish):
print("Dish is on the way")
else:
print("Sorry, this dish is not available")
The kitchen has the order method which tries to fetch items from the storage. Make it return False if an item can't be found in the storage, and True otherwise.
def fetch(self, ingredients):
for ingredient in ingredients:
storage_item = self.check_storage(ingredient)
if storage_item:
storage_item.use(amount=ingredient.amount)
else:
return False
return True
- Open the terminal
- Run the pizzeria class with python
ipython pizzeria.py
- Should display:
"Good day. Welcome to our lovely little restaurant"
"How can I be of service?"
"1. Would you like to order a pizza?"
"2. Would you like to leave?"
- Choose 1
- Should display:
"0. Margherita"
"1. Napoletana"
"2. Pepperoni"
- Choose 1
- Should display
"KITCHEN: Order received for Napoletana"
"Im gonna need some:"
"3 - Tomato"
"0.25 - Dough"
"0.2 - Mozzarella"
"0.05 - Anchovies"
"Sorry this dish is not available"
- Likewise, if you choose pizza #0, the response should be:
"KITCHEN: Order received for Margherita"
"Im gonna need some:"
"3 - Tomato"
"0.25 - Dough"
"0.2 - Mozzarella"
"Dish is on the way"
- Open the terminal
- Add all the files in your project to git
git add .
- Commit the files to your local git
git commit -m 'choices'
[07] As a waiter. I would like to keep track of the items that a customer orders. So that I can present the bill afterwards.
- create an instance variable in the initializer and assign an empty array to it
class Check:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
- import the file containing the check class in the waiter class file
- add an instance variable to the waiter initialize method
self.check = Check()
- call the add method on self.check if food can be ordered
self.check.add(item=chosen_dish)
[08] As a guest. I would like to keep ordering food until there is no stock left or I have had enough. So that I have a good time
- add a boolean to the waiter class as an instance variable
self.serving
. Add it to the initializer and give it the value ofTrue
self.serving = True
while w.serving:
w.serve_guest()
- in the method
take_order()
of Waiter. Set self.serving toFalse
if a guest wants to leave.
- add a price to the arguments of the initializer of the dish class
- assign the price to an instance variable
- in the menu class initializer, add a price for each of the three dishes
- create a method on the check class that loops over all items
- create a local variable sum for this method
- for each dish in the item loop, add the price to the sum
- return the value of sum
def calculate_sum(self):
total_sum = 0
for dish in self.items:
total_sum += dish.price
return total_sum
- let the waiter call this method and tell the value after saying "Thank you for your visit"
[10] As storage. I would like to keep track of my ingredients more efficiently, so I don't have to loop over every single item.
- in your Storage class initializer method, remove the list syntax
[]
with dictionary syntax{}
- use the ingredient names as keys, and the ingredient objects as values
def __init__(self):
self.items = {
Ingredient.TOMATO: Ingredient(name=Ingredient.TOMATO, amount=8),
Ingredient.DOUGH: Ingredient(name=Ingredient.DOUGH, amount=2),
Ingredient.MOZZARELLA: Ingredient(name=Ingredient.MOZZARELLA, amount=1),
Ingredient.PEPPERONI: Ingredient(name=Ingredient.PEPPERONI, amount=0.3)
}
- comment out the
check_storage
method for now
- inside the ingredient loop in
fetch()
, index intoself.items
using the ingredient name - be sure to use
Try/Except
to catch a potentialKeyError
, as we don't know for sure whether all ingredients will be available in the storage - if you catch a
KeyError
, be sure to returnFalse
so the kitchen is updated - if the ingredient is there, use an
else
statement to update the ingredient, as before
def fetch(self, ingredients):
for ingredient in ingredients:
try:
# look up the ingredient in the storage
storage_item = self.items[ingredient.name]
except KeyError:
# the ingredient isn't there
return False
else:
# we now know it's there, so we can use the storage_item
storage_item.use(amount=ingredient.amount)
return True
For more information on syntax errors and exception handling, see: https://docs.python.org/3/tutorial/errors.html
[11] As storage. I would like to check for all my ingredients if inventory is sufficient, before informing the kitchen.
So far the storage has only been checking whether an ingredient existed, rather than doing the full check if each ingredient is available before updating amounts. Let's fix this!
- create an empty checklist array (list in python).
- while looping over your storage items, if an item is not there, append a boolean
False
to your checklist - if the ingredient is present, append
True
.
- check if the boolean
False
appears in you checklist, and store this in variableall_ingredients_are_there
- if all ingredients are there, loop over each ingredient once again, index into
self.items
and update the ingredient amount viause()
- if all ingredients are there, and you've updated the storage items,
print()
the current inventory
print("STORAGE: Inventory is now...")
for ingredient in self.items.values():
print(ingredient.amount, ingredient.name)
- assuming you still have the same ingredients in your initializer, using the print statement, you can now observe how inventory changes when you order a pizza Margherita or pizza Pepperoni.
Note: Sometimes in the inventory you can see amounts like 0.6000000000000001 Mozzarella
. This because we are
using float number and any number that can't be built from exact powers of two can't be represented exactly as a
floating point number; it needs to be approximated. Sometimes the closest approximation will be
less than the actual number. To be exact you can use the type Decimal:
from decimal import Decimal
Decimal('0.6')
The saying goes: untested code is broken code
. In our experience this holds for anything but the most trivial projects.
But doing testing well is in itself also a non trivial exercise. What parts do you test? Are you sure that you are only
testing your code? Are you testing all the edge cases? Besides writing automated tests means introducing more code which
in turn means maintaining a larger codebase.
Python has long ago adopted the so called xUnit testing paradigm, the unit testing work originally introduced in Java
(and Smalltalk) by Kent Beck. Over the years of Python pragmatism has taught us that the xUnit way of working is quite
verbose and sometimes inflexible. Therefore we will use the pytest
testing framework which is the new standard
for
writing tests in Python.
In a test first mentality we would have started with writing tests first :) In practice we find that a less dogmatic approach works best for us. Writing beautiful code is a craft not a procedure. Part of craftsmanship is guaranteeing the quality of what you make. How much confidence do you have in running your code in production?
- Update your requirements.txt and add the following line to it:
pytest==3.0.7
- Run
pip
to update the dependencies within your virtualenv:
$ pip install -r requirements.txt
- Test that pytest works:
$ pytest
The output should be something like:
========================================================= test session starts =========================================================
platform darwin -- Python 3.6.0, pytest-3.0.7, py-1.4.33, pluggy-0.4.0
rootdir: /Users/remco/Tmp/boer, inifile:
collected 0 items
==================================================== no tests ran in 0.00 seconds =====================================================
-
Add a new directory in your project directory called
tests
inside that directory add__init__.py
-
Add a new file inside of the
tests
directory calledtest_check.py
, inside of it add the following code:
from check import Check
from dish import Dish
def test_calculate_sum():
pasta = Dish('Pasta', [], 10)
pizza = Dish('Pizza', [], 15)
c = Check()
# Without any items check sum must be 0
assert c.calculate_sum() == 0
# Adding an item should have sum equal to the item
c.add(pasta)
assert c.calculate_sum() == 10
# Adding another item should have sum equal sum of both items
c.add(pizza)
assert c.calculate_sum() == 25
The test workhorse here is a statement called assert
it tests that the given expression is True
. It takes an
optional secondary expression to output as a detailed message if the assertion doesn't hold. For example:
>>> assert 1==0, 'No way!'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError: No way!
$ pytest
========================================================= test session starts =========================================================
platform darwin -- Python 3.6.0, pytest-3.0.7, py-1.4.33, pluggy-0.4.0
rootdir: /Users/remco/Projects/cours/advanced-python/python-cooking-with-objects/12, inifile:
collected 1 items
tests/test_check.py .
====================================================== 1 passed in 0.01 seconds =======================================================
- Add a file in the
tests
directory calledtest_storage.py
:
from storage import Storage
from ingredient import Ingredient
def test_fetch_empty_ingredients_returns_true():
storage = Storage()
assert storage.fetch(ingredients=[]) is True
def test_fetch_nonexistent_ingredient_returns_false():
storage = Storage()
assert storage.fetch(ingredients=[Ingredient(name='Non existent', amount=8)]) is False
def test_fetch_existent_item_returns_true():
storage = Storage()
assert storage.fetch(ingredients=[Ingredient(name=Ingredient.TOMATO, amount=1)]) is True
def test_fetch_multiple_items_should_work():
storage = Storage()
ingredients = [
Ingredient(name=Ingredient.TOMATO, amount=1),
Ingredient(name=Ingredient.DOUGH, amount=1)
]
assert storage.fetch(ingredients) is True
def test_fetch_too_many_items_returns_false():
storage = Storage()
ingredients = [
Ingredient(name=Ingredient.TOMATO, amount=90),
]
assert storage.fetch(ingredients) is False
Note: that we use the word is
here instead of ==
. is
will return True if two variables point to the same object,
==
if the objects referred to by the variables are equal.
- Run the tests again with
pytest
You will notice that this time something is not going entirely right. Or maybe we should turn that upside down and notice that this test failure is indicating a problem in our code somewhere and giving us the opportunity to fix it. We'll dive into solving these problems in the next chapter.
You probably recognize this moment: something that really shouldn't go wrong has gone wrong. This is impossible! You specifically designed the software in a way that this just shouldn't happen.
In hindsight you just didn't have enough information yet about the behaviour of your code. Of course now that you have you see that this is a nasty, but possible edge case. The question is how to quickly get enough knowledge of this behaviour to fix problems?
There are different strategies for doing this, amongst them:
- Spread out
print()
calls all over your code - Using the python
logging
module to have your software log what it is doing - Using a debugger
Of which we think that using print is in general not a good strategy: you add code that you later need to remove. There is a risk of accidentally committing these print statements. And then you have removed the print statements because you fixed the problem, but later another problem emerges and again you have to sprinkle this print calls all over your code.
The other two ways are good strategies for problem finding. Especially when used together. logging
to have an overall
idea of what your software is doing internally and the debugger to specifically dive into a problematic area and
observe precisely what is happening under the hood. We are going to use both of these techniques to find the problem
from the previous part:
When you use the Python logging
module you can essentially do the same as with print()
but with logging you can
decide through configuration what information is being output by your program at any given time. Some information is
useful to always log: you can imagine if you have developed a program that looks for configuration in different
locations: first in the users home dir: ~/.myconfig
and if that doesn't exist in /etc/myconfig
that you always
want to output which configuration is used explicitly. But in-depth information about how the configuration file is
being parsed is something you only need to see when problems arise.
- Open
pizzeria.py
in your editor and add the logging statements:
import logging
from waiter import Waiter
from menu import Menu
from kitchen import Kitchen
logging.basicConfig(level=logging.DEBUG)
kitchen = Kitchen()
menu = Menu()
w = Waiter(menu=menu, kitchen=kitchen)
w.greet_guest()
logging.info('Restaurant now serving')
while w.serving:
w.serve_guest()
- Open
ingredient.py
and also add logging:
import logging
class Ingredient:
TOMATO = "Tomato"
DOUGH = "Dough"
MOZZARELLA = "Mozzarella"
ANCHOVIES = "Anchovies"
PEPPERONI = "Pepperoni"
def __init__(self, name, amount):
self.name = name
self.amount = amount
def use(self, amount):
self.amount -= amount
logging.debug('Used %s %s', self.amount, self.name)
- Now run your code again, order a pizza, and amongst the output notice the following lines:
INFO:root:Restaurant now serving
DEBUG:root:Used 5 Tomato
DEBUG:root:Used 1.75 Dough
DEBUG:root:Used 0.8 Mozzarella
- Open
pizzeria.py
again and change the logging config line to:
logging.basicConfig(level=logging.INFO)
- Again order a pizza. But notice that this time only the INFO log is shown:
INFO:root:Restaurant now serving
We have effectively changed the logging level. The logging module knows five different logging levels, we list them in order of increasing severity:
- DEBUG: Showing this tends to make you software very verbose, but helps in locating problems.
- INFO: Information you can be interested in during normal execution. Less verbose then DEBUG.
- WARNING: The software can operate normally but warns the user about something.
- ERROR: The software can continue operation but might not work as expected.
- CRITICAL: Execution cannot continue.
There are a lot more possibilities using Python's logging
like creating hierarchies of loggers in large projects. And
only having some of them output information. But this is outside of the scope of this course.
Bonus: Can you adapt the logging configuration in such a way that no logging output shows up?
Reference:
To locate problems precisely we need to sometimes execute part of our program line by line and see what effect each of these lines of execution have on the state of our program. That is what a debugger is for and we will now learn how to debug in Python to solve the problem our test found in the previous part.
Integrated Development Environments like PyCharm feature extensive debuggers that integrate very nicely with your normal development workflow. In this chapter we will start by using a more basic variant. We have used IPython to enhance the Python shell. IPython also features a more user friendly debugger which we will use in this part:
- Install the IPython debugger by adding the following line to
requirements.txt
:
ipdb==0.10.3
-
Then run
pip install -r requirements.txt
-
Something went wrong in one of our test cases. We don't want to use the debugger to step through all of our code because most of it is working correctly. Instead we want to set a breakpoint just before something fails. Set this breakpoint by opening
test_storage.py
and changingtest_fetch_too_many_items_returns_false
:
def test_fetch_too_many_items_returns_false():
storage = Storage()
ingredients = [
Ingredient(name=Ingredient.TOMATO, amount=90),
]
import ipdb; ipdb.set_trace() # Breakpoint
assert storage.fetch(ingredients) is False
Now run pytest -s
(NOTE: -s is needed here) and after a few tests have run you will drop into the debugger:
.> /Users/remco/Projects/cours/advanced-python/python-cooking-with-objects/13/tests/test_storage.py(35)test_fetch_too_many_items_returns_false()
33 ]
34 import ipdb; ipdb.set_trace()
---> 35 assert storage.fetch(ingredients) is False
ipdb>
The debugger is a shell like IPython is, but one specifically made for debugging. Our code has stopped executing just
before storage.fetch()
is called. We want to step into this method since this is where the error is occuring so
type s
and press enter:
ipdb> s
--Call--
> /Users/remco/Projects/cours/advanced-python/python-cooking-with-objects/13/storage.py(14)fetch()
13
---> 14 def fetch(self, ingredients):
15 checklist = []
The debugger has moved our point of execution to the file storage.py
line 14. This is where fetch
is defined. From
here on take some time to step through this method to see what is happening on each line. Do this a couple of times
to get a good feeling for how to work with the debugger. At any time you can type CTR-D
to exit the debugger and just
run pytest -s
again to start over. Here is a legend of the basic instructions:
n(ext)
: Continue execution until the next line in the current function is reached or it returns.s(tep)
: Execute the current line, stop at the first possible occasion (either in a function that is called or in the current function).c(ontinue)
: resume execution normally, possible until a next breakpoint that you defined is hit.ll(ist)
: Print lines of code at current point of execution.p(rint) expression
: Print the value of the expression.h(elp)
: Prints help infoenter
: just execute the last instruction again
BONUS: Now that you know better what fetch()
does, fix the failing test. Remove the breakpoint
after you are done fixing.
For any serious software during the entire lifetime of a project by far the most time is spent in reading, fixing and adapting existing code. Producing code that is easy for both your colleague and future you to pick up on is thus vital. Python idioms have been specially crafted for maximum expressive power with a minimum of code. Following these idioms is called 'writing code that is Pythonic' in the world of Python.
Also a good ritual to maintain high quality is to refactor existing code. As your understanding of what you are working on decisions that you made earlier may currently not be the most effective solution. Or you find out that some code that looks almost the same is used in different parts of your codebase. Luckily during the last part we have invested some of our time to create tests which guarantee our code still respects the contract, even though the implementation changes.
Let's refactor some existing code to make it more Pythonic!
- Open
check.py
the previous implementation we wrote looks like:
class Check:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def calculate_sum(self):
total_sum = 0
for dish in self.items:
total_sum += dish.price
return total_sum
-
Run
pytest
to see that the tests are still working. -
Now change the implementation of the
Check
class to:
class Check:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def calculate_sum(self):
return sum([dish.price for dish in self.items])
That's nice, we have been able to reduce calculate_sum to a oneliner. But what is happening here? In Python the part
within [] is called a list comprehension. Its a short form of building a new list out of an existing Iterable
(an
Iterable is a collection that you can iterate over) in this case what we are saying to Python is: for all dishes
in self.items create a new list contains dish.price. The sum()
function is a function that takes an iterable and
sums all its items.
- Since we changed the implementation let's do a quick
pytest
run. Green? Awesome!
- Open
storage.py
this used to be our implementation:
from ingredient import Ingredient
class Storage:
def __init__(self):
self.items = {
Ingredient.TOMATO: Ingredient(name=Ingredient.TOMATO, amount=8),
Ingredient.DOUGH: Ingredient(name=Ingredient.DOUGH, amount=2),
Ingredient.MOZZARELLA: Ingredient(name=Ingredient.MOZZARELLA, amount=1),
Ingredient.PEPPERONI: Ingredient(name=Ingredient.PEPPERONI, amount=0.3)
}
def fetch(self, ingredients):
checklist = []
for ingredient in ingredients:
try:
# look up the ingredient in the storage
self.items[ingredient.name]
except KeyError:
# the ingredient isn't there, append False
checklist.append(False)
else:
# its there, so append True to our checklist
checklist.append(True)
all_ingredients_are_there = False not in checklist
if all_ingredients_are_there:
for ingredient in ingredients:
# we can index into our dictionary without precaution now because
# we know the ingredients are there
self.items[ingredient.name].use(amount=ingredient.amount)
# print an overview of our inventory, so we can keep track
print("STORAGE: Inventory is now...")
for ingredient in self.items.values():
print(ingredient.amount, ingredient.name)
return all_ingredients_are_there
- Let's also optimize this implementation:
class Storage:
def __init__(self):
self.items = {
Ingredient.TOMATO: Ingredient(name=Ingredient.TOMATO, amount=8),
Ingredient.DOUGH: Ingredient(name=Ingredient.DOUGH, amount=2),
Ingredient.MOZZARELLA: Ingredient(name=Ingredient.MOZZARELLA, amount=1),
Ingredient.PEPPERONI: Ingredient(name=Ingredient.PEPPERONI, amount=0.3)
}
def fetch(self, ingredients):
# Check whether all required ingredients exist in storage and we have enough of them
all_ingredients_are_there = all(
[i.name in self.items and self.items[i.name].amount >= i.amount for i in ingredients])
if all_ingredients_are_there:
# List needs to be called to evaluate map here
list(map(lambda x: self.items[x.name].use(amount=x.amount), ingredients))
print('STORAGE: Inventory is now...')
print('\n'.join([f'{i.amount} {i.name}' for i in self.items.values()]))
return all_ingredients_are_there
We need to do a bit more explaining here, on the first line of the fetch
method you see another list comprehension. It
says the following: walk through all the given ingredients, use its name to check whether there is an entry in the
dictionary. What remains is a list consisting of True and False values. The all()
function here returns True only
if all items in the list are True. Which is the case if all ingredients are there and the amount suffices.
Now reading further we see a line which uses a function called map(function, iterable)
. Map applies a function to
each item in the iterable. For example to calculate the square of 2, 3 and 4 you can do:
def square(x):
return x * x
assert list(map(square, [2, 3, 4])) == [4, 9, 16]
Since a lot of times we just want to use a short helper function that we don't really need outside of the scope of this
specific map()
call, we can use an anonymous function or lambda
. To rewrite the simple example, this is equivalent:
assert list(map(lambda x: x * x, [2, 3, 4])) == [4, 9, 16]
In our more complex case, in the Storage
class, we use the lambda
not to calculate the square of two numbers. But
we just call another method namely Ingredient.use()
to update all the amounts.
The last line that needs some explaining is:
print('\n'.join([f'{i.amount} {i.name}' for i in self.items.values()]))
Again a list comprehension. This loops through the values of the items dictionary in the Storage object. For each item a string is formatted containing the amount and name of the ingredient. This way of formatting strings is new in Python 3.6, see: https://www.python.org/dev/peps/pep-0498/ . Strangely enough it took Python a while to really find a nice syntax for this. What remains is a list of these strings.
join()
is a string method that you call on the string that will act as a separator and then as its argument you
provide an iterable containing strings. This will join the individual string together with the original string. So
in a simpler example this works:
assert ', '.join(['Roel', 'Vincent', 'Remco']) == 'Roel, Vincent, Remco'
- By now I guess you know what comes next:
pytest
All green? Great!
Tests are just code. And maintenance of them can be just as much of a headache as with 'regular' code. So let's also take a moment to rewrite some of our test code into a more clear format.
- Open
tests/test_storage.py
, right now it should look like:
from storage import Storage
from ingredient import Ingredient
def test_fetch_empty_ingredients_returns_true():
storage = Storage()
assert storage.fetch(ingredients=[]) is True
def test_fetch_nonexistent_ingredient_returns_false():
storage = Storage()
assert storage.fetch(ingredients=[Ingredient(name='Non existent', amount=8)]) is False
def test_fetch_existent_item_returns_true():
storage = Storage()
assert storage.fetch(ingredients=[Ingredient(name=Ingredient.TOMATO, amount=1)]) is True
def test_fetch_multiple_items_should_work():
storage = Storage()
ingredients = [
Ingredient(name=Ingredient.TOMATO, amount=1),
Ingredient(name=Ingredient.DOUGH, amount=1)
]
assert storage.fetch(ingredients) is True
- Rewrite the code to be the following:
import pytest
from storage import Storage
from ingredient import Ingredient
@pytest.mark.parametrize('ingredients, expected', [
[[], True],
[[Ingredient(name='Non existent', amount=8)], False],
[[Ingredient(name=Ingredient.TOMATO, amount=1)], True],
[[Ingredient(name=Ingredient.TOMATO, amount=1), Ingredient(name=Ingredient.DOUGH, amount=1)], True],
[[Ingredient(name=Ingredient.TOMATO, amount=90)], False],
])
def test_fetch(ingredients, expected):
storage = Storage()
assert storage.fetch(ingredients) == expected
pytest
calls this a parametrized test. It lets you define flexible define parameters that should be given to a
function for every case. And then simple allows you to define the structure of the test once. Saving you a lot of
overhead for creating separate test functions.
- Do we have a green light?
[15] As developer. I would like to document my code for others to easily use it. Including my future self.
As was the case with testing we have so far abstained a bit from really documenting our code. And as we have done with testing in the part before this, let's correct this by taking a few moments to document part of what we have build.
Pragmatism has taught us that when writing new code it is wise to first allow an API to stabilize a little bit. If you fanatically start to document everything right from the start you might end up spending a lot of your energy in documenting things that you may throw away a couple of hours later. Our supply of energy is limited.
For documentation purposes Python makes a difference between comments and so called docstrings:
Comments you have already seen in the code from earlier parts:
def fetch(self, ingredients):
# Check whether all required ingredients exist in storage and we have enough of them
all_ingredients_are_there = all(
[i.name in self.items and self.items[i.name].amount >= i.amount for i in ingredients])
if all_ingredients_are_there:
# List needs to be called to evaluate map here
list(map(lambda x: self.items[x.name].use(amount=x.amount), ingredients))
You write comments with the intention of explaining mostly your intentions as developer for someone that is changing
the implementation of existing code. If at a later point I need to change the implementation of fetch()
I quickly
see what all_ingredients_are_there holds and why I use the list()
call after the map. The rest of the code is
self-explanatory.
This self-explantory part is important. Python has been designed for easy readability (after you get used to its syntax of course ;) so try to keep comments to a minimum. Especially watch out for stating in a comment what the code itself already clearly shows, DON'T do something like this:
# DON'T DO THIS!
# Loop through all the items and for each item print the amount and name
for ingredient in self.items.values():
print(ingredient.amount, ingredient.name)
Docstrings are there to document the interface or contract of your code. This is different then a comment. In this case I don't care about the implementation of a specific class or method. I just care about using it. So what do I need to know to make good use of your class or method?
You can easily lookup docstrings when you are working in the IPython shell, to lookup documentation for example
about os.path.join
, do the following:
>>> import os.path
>>> %pdoc os.path.join
Class docstring:
Join two or more pathname components, inserting '/' as needed.
If any component is an absolute path, all previous path components
will be discarded. An empty last part will result in a path that
ends with a separator.
- We are going to document our
Storage
class, openstorage.py
and add the documentation:
from ingredient import Ingredient
class Storage:
"""Storage for ingredients used by Pizzeria
This class holds all ingredients available to create pizza's in our restaurant. On creation the Storage
starts with a fixed set of Ingredients:
8 Tomato
2 Dough
1 Mozzarella
0.3 Pepperoni
Attributes:
items (:obj:`dict` with :obj:Ingredient): Ingredients available in this storage
"""
def __init__(self):
self.items = {
Ingredient.TOMATO: Ingredient(name=Ingredient.TOMATO, amount=8),
Ingredient.DOUGH: Ingredient(name=Ingredient.DOUGH, amount=2),
Ingredient.MOZZARELLA: Ingredient(name=Ingredient.MOZZARELLA, amount=1),
Ingredient.PEPPERONI: Ingredient(name=Ingredient.PEPPERONI, amount=0.3)
}
def fetch(self, ingredients):
"""Fetch items from storage
This method checks to see whether the enough required Ingredients are present in
the Storage and updates the amounts accordingly.
Args:
items (:obj:`list` of :obj:Ingredient): Items to be fetched
Returns:
True if successful, False otherwise.
"""
# Check whether all required ingredients exist in storage and we have enough of them
all_ingredients_are_there = all(
[i.name in self.items and self.items[i.name].amount >= i.amount for i in ingredients])
if all_ingredients_are_there:
# List needs to be called to evaluate map here
list(map(lambda x: self.items[x.name].use(amount=x.amount), ingredients))
print('STORAGE: Inventory is now...')
print('\n'.join([f'{i.amount} {i.name}' for i in self.items.values()]))
return all_ingredients_are_there
- BONUS: document the
Waiter
class yourself
The Python ecosystem has a very nice tool to help us with documentation, it's called Sphinx. Sphinx was originally developed for documenting Python itself: https://docs.python.org/3/. Sphinx uses can automatically extract these docstrings to build a documenting without code that is easy to use as a reference. In these docstrings you can even write code as examples that can be automatically tested!
Resources:
- Docstring conventions: https://www.python.org/dev/peps/pep-0257/
- Sphinx: http://www.sphinx-doc.org/en/stable/
- Google docstring style guide: http://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html
We have come to the end of the course! Well done!
We have bombarded you with a lot of knowledge, and had you type along with us. This allowed us to touch a lot of different subjects in a very short amount of time. Of course there is much more to know. But the small project that you have in your possession right now is a good reference of the different subjects that we touched. With this knowledge you hold all the cards in your hand to pragmatically write high quality Pythonic code!
This assignment is for those of you who have any time left over in the course or who are enthusiastic about practicing lessons learned in this course at home.
Build a Supplier
class that can be used to top up the amounts of the different Ingredients in the Storage
class.
You can imagine that this supplier somehow needs to be called to top up stock when a new Pizza is ordered but there
are not enough Ingredients available. You are free to implement this any way you see fit. Use the lessons learned in
earlier parts. Try to write unit tests for the code you are building. Maybe even go test first here.