02: Namespace, modules, packages, and objects
There are a variety of ways to import existing code into a Python script or interactive session.
There is a lot of flexibility in how this is done, but a few suggested practices will be covered here.
[1]:
import this
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
In the above Easter Egg, we can learn a couple things. First, the end line highlights that namespaces are important!
Also, by importing this, it actually executed some code (printing out the Zen of Python). This means Python knew where to find a module called this and executed it upon import.
Let’s try another easter egg, just for fun.
[2]:
import antigravity
The goal of this notebook is to provide students with the skills needed to create resuable functions, objects, and python moduldes.
By the end of this notebook the student will have the skills required to create a complex object in a seperate python script, import it’s functionality into this notebook, and be able to work with it here.
Namespaces
There’s a nice explanation of namespaces here.
First, we need to understand what is a name in Python. A name is a general container referencing something. Like in many languages, think of a variable:
[3]:
a=5
a
[3]:
5
In python, we can also use a name for a function.
[4]:
def funky(description):
print (f'this {description} function is funky!')
[5]:
funky
[5]:
<function __main__.funky(description)>
[6]:
funky('Town')
this Town function is funky!
[7]:
f = funky
f
[7]:
<function __main__.funky(description)>
[8]:
f("Skunk")
this Skunk function is funky!
So, we assigned f to, in a sense, point to the function funky.
Names (and therefore variables) can assume various types and get reused without definition.
[9]:
a=5
print (a)
a = [12.3, 44.9]
print (a)
a = 'stuff in quotes'
print (a)
5
[12.3, 44.9]
stuff in quotes
So, namespace is just a space containing all the names in use during a Python session.
An important caution with names:
Since you can think of a name of a variable as a tag, there is a special behavior related to lists that can cause massive grief!
First, what happens when a single value is associated with a name (like a variable)
[10]:
a = 5
b = a
print(f"{a=}, {b=}")
b = 6
print(f"{a=}, {b=}")
a=5, b=5
a=5, b=6
Now what happens when we have a list and change an element in b…
[11]:
a=[1.0, 2.0, 3.5, 4.9]
print (f'{a=}')
b=a
print (f'{b=}')
print ('_'*15)
b[2]=999
print (f'{a=}')
print (f'{b=}')
a=[1.0, 2.0, 3.5, 4.9]
b=[1.0, 2.0, 3.5, 4.9]
_______________
a=[1.0, 2.0, 999, 4.9]
b=[1.0, 2.0, 999, 4.9]
Oh no! Changing ``b`` also changed ``a``!
The reason for this is that a and b are both pointing to the same memory location that’s storing the information (in this case, starting with the list [1.0, 2.0, 3.5, 4.9] and later becoming the list [1.0, 2.0, 999, 4.9]). This same behavior happens when using numpy arrays.
The way around this is to make a full copy of the information (by value rather than by reference). In typical Python, this means importing a module called copy and using either the function copy.copy or copy.deepcopy. In numpy, copy is built-in.
[12]:
import copy
a = [1,2,3]
b = copy.copy(a)
b[2] = 99
print (a)
print (b)
[1, 2, 3]
[1, 2, 99]
Namespaces: Global to Local
Python contains four common namespaces. We are going to investigate the behaviour of three of these:
[13]:
x = "global"
def f():
x = "enclosing"
print(x)
def g():
x = "local"
print(x)
g()
print(x)
print(x)
f()
print(x)
global
enclosing
local
enclosing
global
Now remove x = "enclosing" and/or x = "local" and run the code. What’s happening here?
[14]:
x = "global"
def f():
print(x)
def g():
x = "local"
print(x)
g()
print(x)
print(x)
f()
print(x)
global
global
local
global
global
Objects
Python supports object-oriented programming. This is, in fact, awesome! It can, however, be confusing at first. Let’s break it down…
Staring with a few definitions. First of all, basically everything in Python is an object. You can think of the word “object” to mean “thing”. Any of these things–or objects–can have both attributes and methods.
Attributes are just data associated with (or stored by) an object
Methods are functions that do something with that data (or with other data).
Properties are special functions that do something with data, but behave like attributes.
A class is a set of definitions for the data structure and methods of an object. You can think of this like a blueprint.
An instance is an object using the definitions of a class. You can think of this as a building made from the blueprint.
Let’s try out some examples.
[15]:
def hola():
print("hello world")
return 42, "african or european?"
[16]:
hola
[16]:
<function __main__.hola()>
[17]:
meaning_of_life, sparrow_velocity = hola()
hello world
[18]:
print(f"{meaning_of_life=}, {sparrow_velocity=}")
meaning_of_life=42, sparrow_velocity='african or european?'
[ ]:
[19]:
class Person:
def __init__(self, input_name, input_fav):
self.name = input_name
self.fav = input_fav
def introduce_yourself(self):
print (f"Hi, I'm {self.name}. I like {self.fav}")
[20]:
Person
[20]:
__main__.Person
[21]:
Fred = Person('Fredrick', 'beer')
Fred.name
[21]:
'Fredrick'
[22]:
Fred
[22]:
<__main__.Person at 0x20f3be6cc20>
[23]:
Fred.introduce_yourself()
Hi, I'm Fredrick. I like beer
A More Useful Class*
*marginally more useful
[24]:
class Rectangle(object):
"""
this is a doc string
"""
#this is just a comment
def __init__(self, x, y, ID):
self.length = x
self.width = y
self.ID = ID
[25]:
print(Rectangle)
<class '__main__.Rectangle'>
[26]:
r1 = Rectangle(2,3,'f')
print(r1.length)
print(r1.ID)
r2 = Rectangle(5,5,'dd')
2
f
[27]:
r2.length
[27]:
5
[28]:
all_my_rectangles = [r1,r2]
[29]:
all_my_rectangles[0].length
[29]:
2
[30]:
for rect in all_my_rectangles:
print(rect.ID)
f
dd
Here, we’ve set up a class from which we can create instances later. Note that the syntax looks like a function. There are a couple strange things that deserve an explanation.
The argument
objectis optional and has to do with inheritance (which will only be briefly introduced in this class).It is common to include at least one method
__init__is a special operator that initializes the class.The first argument of
__init__and really any method of a class isself.
More about self
self is the instance of the class that is being operated on. One could use a different name, but it is convention (deeply seated!!) to use self. A nice explanation is found on Stack Overflow and Guido van Rossum wrote an essay on why explicit self can’t go away.
Here’s one more explanation of the use and need for self self history.
Basically, it comes down to Explicit is better then implicit. We want to know explicitly that we are working on an a property of the object we are defining rather than some other function or variable that might be globally defined.
Now let’s make an instance and try all this out.
[31]:
big_rectangle = Rectangle(25, 35, 'rectangle one')
big_rectangle
vars(big_rectangle)
[31]:
{'length': 25, 'width': 35, 'ID': 'rectangle one'}
[32]:
big_rectangle.length
[32]:
25
We see now that we’ve made an instance and it is of the type rectangle. We can check out the attributes using a dot (.).
[33]:
print(big_rectangle.width)
print(big_rectangle.length)
print(big_rectangle.ID)
35
25
rectangle one
Test your skills
Can we create a list or dictionary of Rectangle objects?
keys —> ‘R1’ ‘R2’
[34]:
widths = [21, 10, 14]
heights = [5, 3, 10]
ids = ["r1", "r2", "r3"]
[35]:
# list
rects = []
for lbl, w, h in zip(ids, widths, heights):
rects.append(Rectangle(w, h, lbl))
rects
[35]:
[<__main__.Rectangle at 0x20f3be34fc0>,
<__main__.Rectangle at 0x20f3be350f0>,
<__main__.Rectangle at 0x20f3bdf5b50>]
[36]:
# dict
rdict = {}
for lbl, w, h in zip(ids, widths, heights):
rdict[lbl] = Rectangle(w, h, lbl)
rdict
[36]:
{'r1': <__main__.Rectangle at 0x20f3be4caf0>,
'r2': <__main__.Rectangle at 0x20f3be4d150>,
'r3': <__main__.Rectangle at 0x20f3bc15750>}
[37]:
print(rdict["r2"].width)
print(rects[0].length)
3
21
There are advantages to both approaches. It would also be possible to define each attribute as a list or dictionary and make a single class. This is a bit more cumbersome, though, and part of the flexibility of dynamic lists and dictionaries is the ability to define multiple objects within them on the fly.
Methods
Now say we want to operate on these data, like to calculate the area of each rectangle.
[38]:
class Rectangle(object):
"""
this is a doc string
"""
#this is just a comment
def __init__(self, x, y, ID):
self.length = x
self.width = y
self.ID = ID
# live code methods
def calc_area(self):
return self.length * self.width
[39]:
r8 = Rectangle(4, 6.2, "r8")
r8.calc_area()
[39]:
24.8
[ ]:
Test your skills!!!
build an even better Rectangle class that includes an area and perimeter method.
[40]:
class Rectangle(object):
"""
this is a doc string
"""
#this is just a comment
def __init__(self, x, y, ID):
self.length = x
self.width = y
self.ID = ID
# live code methods
def calc_area(self):
return self.length * self.width
def calc_perimeter(self):
return 2 * (self.length + self.width)
[41]:
r34 = Rectangle(3, 4.345, "another_one")
[42]:
print(f"area={r34.calc_area()}, perimeter={r34.calc_perimeter()}")
area=13.035, perimeter=14.69
A note on property methods
Properties are a special method that behaves similar to an attribute. These methods allow for on the fly (“dynamic”) calculations and variable construction among other things.
Properties are defined with a special decorator (@property). Decorators are an advanced topic and won’t be covered in this course. More information about decorators, how they are used, and how they work can be found here.
[43]:
class Rectangle(object):
def __init__(self, x, y, ID):
self.length = x
self.width = y
self.ID = ID
@property
def area(self):
return self.length * self.width
[ ]:
[ ]:
Just for funsies, let’s extend the class to check if we made a golden rectangle
In mathematics, the golden ratio between two values occurs if their ratio is the same as the ratio of their sum over the larger of the two quantities. We can represent that as:
\(\frac{a + b}{a} = \frac{a}{b}\)
For a rectangle:
a = shorter side
b = longer side - shorter side
[44]:
class Rectangle(object):
def __init__(self, x, y, ID):
self.length = x
self.width = y
self.ID = ID
@property
def area(self):
return self.length * self.width
@property
def perimeter(self):
return 2 * (self.length + self.width)
@property
def is_golden(self):
if self.length > self.width:
a = self.width
b = self.length - self.width
else:
a = self.length
b = self.width - self.length
if abs(((a + b) / a) - (a / b)) < 0.01:
return True
else:
return False
[45]:
golden = Rectangle(18.52, 30, "is_it_golden?")
golden.is_golden
[45]:
True
[ ]:
Modules, Packages, and the Standard Python Library
The Standard Python Library is the set of functions that are part of Python by default.
More technically, names point to “objects”. a “module” is a file (with extension .py) that contains python code. If there are functions in that code, they can be accessed using the name of the module and a dot (.).
Packages are collections of modules and are often “installed” to be accessible to Python from anywhere. More on that at the end of the lesson.
Let’s import a module like random and find a function within it.
[46]:
# live code example
import random
[47]:
random.random
[47]:
<function Random.random()>
[48]:
random.random()
[48]:
0.28743741543767465
Importing code and handling namespaces
There are several main ways to import a module.
The most straightforward way is to just use import <somepackage> as we did above.
[49]:
import math
math
[49]:
<module 'math' (built-in)>
This then shows that numpy is a module. Whenever you want to use a function from numpy, you just use the dot like math.sqrt.
The main advantage to this approach is you always know the provenance of any function. Also, you could (bad idea!) make your own functions called sqrt.
[50]:
def sqrt(numb):
# newton's method
def f(i0, numb):
return (i0 ** 2) - numb
def f_prime(i0):
return 2 * i0
err = 100000
i0 = 5
while err > 0.01:
i1 = i0 - f(i0, numb) / f_prime(i0)
err = abs(i1 - i0)
i0 = i1
print (f'my complicated function estimates sqrt as--> {i1}!')
[51]:
# live code example
math.sqrt
[51]:
<function math.sqrt(x, /)>
[52]:
sqrt(5)
my complicated function estimates sqrt as--> 2.2360688956433634!
[53]:
math.sqrt(5)
[53]:
2.23606797749979
Another option is to import only some function you need from a module like from math import sqrt. The problem here is, we don’t necessarily know where this came from. Whichever was either imported or created most recently gets that name in the namespace. DANGER!
[54]:
from math import sqrt
sqrt
[54]:
<function math.sqrt(x, /)>
You can also use an alias to import a specific function like from math import sqrt as math_sqrt. In this case, and in the case above, you can get the provenance from the import statements at the top of the code, but if the code gets really long, this can be hard to keep track of.
[55]:
from math import sqrt as math_sqrt
math_sqrt
[55]:
<function math.sqrt(x, /)>
Living really dangerously, you can import all functions from a module like from math import *
[56]:
from math import *
sqrt, log, log10, floor, ceil
[56]:
(<function math.sqrt(x, /)>,
<function math.log>,
<function math.log10(x, /)>,
<function math.floor(x, /)>,
<function math.ceil(x, /)>)
The problem here is, you now have access to all these functions, but you also don’t know provenance at all. Some modules, like numpy, which will be covered later in this class, are large and have many functions (many of which may have common names that you might use yourself and that you might not be aware of).
So…..really, the safest way is like the first way, but that can get long (for example, if you use import matplotlib, then every time you use a function from the module you have to type matplotlib.<some function> and that gets verbose. A compromise is importing an entire module but assigning it an alias like import numpy as np
[57]:
import numpy as np
There is a commonly accepted set of aliases for some common scientific computing modules that we recommend:
import matplotlib.pyplot as pltimport numpy as npimport matplotlib as mplimport pandas as pd
In addition to keeping the provenance straight, adopting this protocol helps make your code more readable by other people. Remember the Zen of Python!!!
[58]:
import this
print("".join([this.d.get(c, c) for c in this.s]))
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
Paths for importing and installation
From the official documentation, the hierarchy of searching for modules and packages is:
the directory containing the input script (or the current directory).
PYTHONPATH (a list of directory names, with the same syntax as the shell variable PATH).
the installation-dependent default.
The PYTHONPATH variable is a system variable on Windows and thus requires an administrative account to change/add to. You can see your search path using the built-in sys module.
[ ]:
import sys
sys.path
Exercise: putting it all together
In this exercise we’ll create our first module and import it into this notebook.
Open the python file “circle_module.py” in an IDE or text file and create a class called Circle. Inputs to circle should be a radius and ID. Include in the Circle class a way to the calculate area and the circumference. After building the class, try importing into this notebook and using the it.
Bonus exercise: Find a way to make the Circle objects divisible and compare the difference in area between a 12” and 14” pizza.
[60]:
from circle_module import Circle
[61]:
med = Circle(12, 'medium')
large = Circle(14, "large")
print(med/large)
0.7346938775510204
[ ]:
[ ]:
[ ]: