Python Object-Oriented Programming – Training

Yoan Mollard

CHAPTER 1: PREREQUISITES
1.1. Python types and protocols
1.2. Reminder about iteration with for and while
1.3. Reminder about function definition
1.4. Type annotations (aka type hints)

CHAPTER 2: OBJECT-ORIENTED PROGRAMMING (O.O.P.)
2.1. Exercise part 1: The basic scenario
2.2. Advanced OOP methods and properties
2.3. Exercise part 2: The blocked account
2.4. Exercise part 3: The account with agios

CHAPTER 3: MODULES, PACKAGES AND LIBRARIES
3.1. Structure of Python packages
3.2. The Python Package Index (PyPI.org)
3.3. Exercise part 4: The account package
3.4. Testing
3.5. Exercise part 5: Test your package
3.6. Decorators and closures
3.7. Exercise part 6: decorators

CHAPTER 1

PREREQUISITES

Python types and protocols

Primitive types

i = 9999999999999999999999999                   # int (unbound)
f = 1.0                                         # float
b = True                                        # bool
n = None                                        # NoneType (NULL)

🚨 Beware with floats

Python floats are IEEE754 floats with mathematically incorrect rounding precision:

0.1 + 0.1 + 0.1 - 0.3 == 0    # This is False 😿
print(0.1 + 0.1 + 0.1 - 0.3)  # Returns 5.551115123125783e-17 but not 0

Also, they are not able to handle large differences of precisions:

1e-10 + 1e10 == 1e10          # This is True 😿

When you deal with float number and if precision counts, use the decimal module!

from decimal import Decimal
Decimal("1e-10") + Decimal("1e10") == Decimal("1e10")   # This is False 🎉

Beware not to initialize Decimal with float since the precision is already lost: Decimal(0.1) will show Decimal('0.10000000000000000555111512312578270215')

Reminder about basic collections

Collections allow to store data in structures.

General purpose built-in containers are tuple, string, list, and dict.

Other containers exist in module 🐍 collections.

The tuple

The tuple is the Python type for an ordered sequence of elements (an array).

t = (42, -15, None, 5.0)
t2 = True, True, 42.5
t3 = (1, (2, 3), 4, (4, 5))
t4 = 1,

Selection of an element uses the [ ] operator with an integer index starting at 0:

element = t[0]  # Returns the 0th element from t 

Tuple can be unpacked:

a, b = b, a   # Value swapping that unpacks tuple (b, a) into a and b

The string

The str type is an ordered sequence of characters.

s = "A string"
s2 = 'A string'           # Simple or double quotes make no difference
s3 = s + ' ' + s2         # Concatenation builds and returns a new string
letter = s2[0]            # Element access with an integer index

Tuples and strings are immutable. Definition: An object is said immutable when its value canot be updated after the initial assignment. The opposite is mutable.

Demonstration: put the first letter of these sequences in lower case:

s = "This does not work"
s[0] = "t"
# TypeError: 'str' object does not support item assignment

The list

A list is a mutable sequence of objects using integer indexes:

l = ["List example", 42, ["another", "list"], True, ("a", "tuple")]

element = l[0]             # Access item at index 0
l[0] = "Another example"   # Item assignment works because the list is mutable

some_slice = l[1:3]  # Return a sliced copy of l between indexes 1 (inc.) & 3 (ex.)

42 in l    # Evaluates to True if integer 42 is present in l

l.append("element") # Append at the end (right side)
element = l.pop()             # Remove from the end.

If needed, pop(i) and insert(value, i) operate at index i, but...

... ⚠️ list is fast to operate only at the right side!

Need a left-and-right efficient collection? Use 🐍 deque or 🐍 compare efficiency

The double-ended queue (deque)

A deque is great to append or remove elements at both extremities:

from collections import deque
queue = deque(["Kylie", "Albert", "Josh"])
queue.appendleft("Anna")   # list.insert(0, "Anna") would be slow here: O(n)
queue.popleft()    # list.pop(0) would be slow here: O(n)

Deques perform great for appendleft() and popleft() while lists have poor performances for the equivalent operations insert(0, value) and pop(0).

The dictionary

The dictionary is a key-value pair container, mutable and ordered. Keys are unique.

d = {"key1": "value1", "key2": 42, 1: True} 
# Many types are accepted as keys or values, even mixed together

"key1" in d   # Evaluates to True if "key1" is a key in d
# Operator "in" always and only operates on keys

d["key2"]    # Access the value associated to a key

d.keys()     # dict_keys ["key1", "key2", 1]

d.values()   # dict_values ["value1", 42, True]

d["key3"] = "some other value"   # Insertion of a new pair

d.update({"key4": "foo", "key1": "bar"})

With Python 3.7 and below, dictionaries are unordered (see OrderedDict if needed)

Reminder about iteration with for and while

Iteration on list i (or tuple t)

for i in range(len(l)):
    print(f"{l[i]} is the value at index {i}")

for v in l:
    print(f"{v} is the value at index {i}")
    # Warning: v is a copy: any modification of v will remain local

for i, v in enumerate(l):
    print(f"{v} is the value at index {i}")

Iteration on string s

for c in "Hello":
    print(f"The next letter in that string is {c}")

Iteration with a while loop

i = 0  # All variables in the condition must preexist (here, i and l)
while i < len(l) and l[i] == 0:
    i += 1 

Iteration on dict d

for k in d:    # by default, "in" operates on KEYS
    print(f"Value at key {k} is {d[k]}")

for i, k in enumerate(d):
    print(f"Value at key {k} is {d[k]} at position {i}")

for k, v in d.items():
    print(f"Value at key {k} is {v}")
    # Useful for nested dictionaries

List-comprehensions and dict-comprehensions

A comprehension is an inline notation to build a new sequence (list, dict, set).
Here is a list-comprehension:

l = [i*i for i in range(10)]  # Select i*i for each i in the original "range" sequence
# Returns [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

You may optionally filter the selected values with an if statement:

l = [i*i for i in range(100) if i*i % 10 == 0]  # Select values that are multiple of 10
# Returns [0, 100, 400, 900, 1600, 2500, 3600, 4900, 6400, 8100]

l = [(t, 2*t, 3*t) for t in range(5)] # Here we select tuples of integers:
# Returns [(0, 0, 0), (1, 2, 3), (2, 4, 6), (3, 6, 9), (4, 8, 12)]

Dict-comprehensions also work:

d = {x: x*x for x in range(10)}
# Returns {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49, 8: 64, 9: 81}

Reminder about function definition

def compute(a, b, c, d):
    number = a + b + c + d
    return number

the_sum = compute(1, 1, 1, 1)

No return statement is equivalent to return None

The star * is the flag that means 0 or n values. They are received in a list:

def compute(*args):
    sum, difference, product, quotient = 0, 0, 1, 1
    for value in args:   # args is a list
        sum += value
        difference -= value
        product *= value
        quotient /= value
    return sum, difference, product, quotient

sum, *other_results = compute(42, 50, 26, 10, 15)

A named parameter is passed to a function via its name instead of its position:

def sentence(apples=1, oranges=10):
   return f"He robbed {apples} apples and {oranges} oranges"

p = sentence(2, 5)
p = sentence()
p = sentence(oranges=2) 

The double star ** in the flag that means 0 or n named parameters. They are received as a dictionary:

def sentence(**kwargs):
    for item, quantity in kwargs.items():  # kwargs is a dict
        print(f"He robbed {quantity} {item}")

sentence(apples=2, oranges=5)
# He robbed 2 apples
# He robbed 5 oranges

One can return 2 values or more:

def compute(a, b):
   return a + b, a - b, a * b, a / b

Call to compute() returns a tuple:

results = compute(4, 6)
the_quotient = results[3]

This tuple can also be unpacked:

the_sum, the_difference, the_product, the_quotient = compute(4, 6)
the_sum, *other = compute(4, 6)      # Unpacking N elements into a list

The star usually means 0 or N element(s)

Type annotations (aka type hints)

Any Python variable can optionnally be associated to a type hint:

def compute(a: int, b: int) -> int:
    return a + b

Inconsistent types and values are NOT noticed by the interpreter.

Annotations are ONLY intended to an (optional) type checker, such as PyCharm.

my_value : int = compute(5, 5)   # OK: Type checking passes
s: bool = compute(5.0, 5)
# Linter warning: Expected "int", got "float" instead
# Linter warning: Expected "bool", got "int" instead

compute(5, 5).capitalize()
# Linter warning:  Unresolved attribute reference "capitalize" for "int"

To specify more complex annotations, import them from typing:

  • Any: every type
  • Union[X, Y, Z]: one among several types (e.g. int, float or str)
  • Callable[[X], Y]: function that takes X in input and returns Y
  • Optional[X]: either X or NoneType
  • ForwardRef(X): forward reference to X, used to circumvent circular imports
from typing import Union

def sum(a: Union[int, float], b: Union[int, float]) -> Union[int, float]:
    return a+b

sum(5.0, 5) # Now, this call is valid for the type checker

Data containers can also be fully typed, e.g. list[list[int]], dict[str: float]

CHAPTER 2

OBJECT-ORIENTED PROGRAMMING (O.O.P.)

Bases of OOP

apartment_available = True
apartment_price = 90000

def sell():
   apartment_available = False

def reduce_price(percentage=5):
   apartment_price = apartment_price * (1-percentage/100)

Note: because of the scope of variables, global variables would be required here

In classic programming, these are variables...

apartment_available = True
apartment_price = 90000

... and these are functions:

def sell():
   apartment_available = False

def reduce_price(percentage=5):
   apartment_price = apartment_price * (1-percentage/100)

However, functions usually manipulate on data stored in variables. So functions are linked to variables.

In Object-Oriented Programming, variables and functions are grouped into a single entity named a class that behaves as a data type:

class Apartment:
    def initialize_variables():
        apartment_available = True
        apartment_price = 90000

    def sell():
        apartment_available = False

    def reduce_price(percentage=5):
        apartment_price = apartment_price * (1-percentage/100)

Note: this intermediary explanation is not yet a valid Python code snippet

Object-Oriented Programming introduced specific vocabulary:

Types are called classes:

class Apartment:   

Functions are called methods:

    def sell():    

Variables are called attributes:

        apartment_available = False

Since the declaration of a class defines a new type (here, Apartment), the program can declare several independant apartments:

apartment_dupont = Apartment()
apartment_muller = Apartment()

apartment_dupont.reduce_price(15)
apartment_muller.reduce_price(7)
apartment_dupont.sell()
apartment_muller.reduce_price(3)
apartment_muller.sell()
apartment_dupont = Apartment()

In this statement:

  • Apartment is a class
  • apartment_dupont is an object (an instance of a class)
  • Apartment() is the constructor (the method creating an object out of a class)
apartment_dupont.reduce_price(15)

This statement is a method call on object apartment_dupont.

Method calls can create side effects to the object (modifications of its attributes).

Like regular functions, methods can take parameters in input. Here, an integer, 15.

The self object

  • self is the name designating the instanciated object
  • self is implicitly passed as the first argument for each method call
  • self can be read as "this object"

In other languages like Java or C++, self is named this.

The constructor

The constructor is the specific method that instanciates an object out of a class. It is always named __init__.

class Test:
    def __init__(self):
        self.attribute = 42

Here is now a valid Python syntax for our class.

This is the class declaration:

class Apartment:
    def __init__(self):       # Implicit first parameter is self
        self.available = True       # We are creating an attribute in self
        self.price = 90000

    def sell(self):
        self.available = False

    def reduce_price(self, percentage=5):
        self.price = self.price * (1-percentage/100)

This is the class instanciation:

apart_haddock = Apartment()

The constructor, like any other method, can accept input parameters:

class Apartment:
    def __init__(self, price):
        self.available = True	
        self.price = price

apart_dupont = Apartment(120000)    # Now the price is compulsory
apart_haddock = Apartment(90000)

While attributes are accessed using the prefix self. from inside the class...

...they can be accessed from outside the class, using object name as the prefix:

print(f"This flat costs {apart_haddock.price}")
apart_haddock.available = False

However some attributes may have a protected or private scope:

class Foo:
    def __init__(self):
        self.public = 0
        self._protected = 0
        self.__private = 0        # ⚠ Name mangling applies here

Protected attributes are not enforced but private ones rely on name mangling:

class BankAccount:
    def __init__(self):
        self.__balance = 3000
         
class Client:
    def make_transaction(self, bank_account: "BankAccount"):
        bank_account.__balance += 1000
         
Client().make_transaction(BankAccount())
# AttributeError: 'BankAccount' object has no attribute '_Client__balance'

Exercise part 1: The basic scenario

In this exercise we are going to create a simplified Information System that is able to handle and simulate bank transactions.

In our scenario there are 4 actors: a bank (HSBC), a supermarket (Walmart), and 2 individuals Alice and Bob.

Each actor has his/her own bank account.

⚠️ Use type annotations for all variables, input parameters, and attributes!

  • 1.1. Create a class BankAccount that owns 2 attributes:
    • a private owner (of type str): the owner's name
    • a protected balance (of type int): the balance (Decimals do not matter)
    • the class constructor takes in parameter, owner and initial_balance

With your class it must be possible to execute the following scenario:

bank = BankAccount("HSBC", 10000)
walmart = BankAccount("Walmart", 5000)
alice = BankAccount("Alice Worz", 500)
bob = BankAccount("Bob Müller", 100)
  • 1.2. Implement print() in class BankAccount to print a f-string stating the owner and current balance. Loop over all accounts to print them.

  • 1.3. Implement these methods :

    • _credit(value) that credits the current account with the value passed in parameter. We will explain the goal of the initial underscore later.
    • transfer_to(recipient, value) that transfers the value passed in parameter to the recipient passed in parameter
  • 1.4. Run the following scenario and check that end balances are right:

    • 1.4.1. Alice buys $100 of goods at Walmart
    • 1.4.2. Bob buys $100 of goods at Walmart
    • 1.4.3. Alice makes a donation of $100 to Bob
    • 1.4.4. Bob buys $200 at Walmart

Advanced OOP methods and properties

Class methods

While a regular method f(self) is an instance method because it applies to instance self, class methods apply to the class instead.

Their first parameter is no longer the instance self but the class type cls:

class Animal:
    @classmethod
    def define(cls):
        return f"An {str(cls)} is an organism in the biological kingdom Animalia."

Thus it is possible to call the class method from the class or the instance:

Animal.define()
Animal().define()

Static methods

Unlike instance methods and class methods, static methods do not receive any implicit parameter such as self or cls:

class Animal:
    @staticmethod
    def define():
        return "Animals are organisms in the biological kingdom Animalia."

They can be called on a class or an instance:

Animal.define()
Animal().define()

💡 Class and static methods are close concepts, but use the first only if you need the class type in parameter.

Properties, getters and setters

A Python property is an entity able to get, set or delete the attribute of an object

Its C++ equivalent are getters and setters e.g. car.getSpeed() & car.setSpeed(1.0)

Properties are useful to add code filters to public attributes

  • Example: raise exceptions when attributes are set to inconsistent values
  • Example: make sure that the self.month integer is between 1 and 12
  • Example make an attribute read-only (with a getter but no setter)

Create a property with the property() function or the @property decorator

property(fget=None, fset=None, fdel=None, doc=None)

Where:

  • fget is a function to get the value of the attribute (the getter)
  • fset is a function to set the value of the attribute (the setter)
  • fdel is a function to delete the attribute
  • doc is a docstring
class Date:
    def __init__(self):
        self.__month = 0
    
    def get_month(self):
        if self.__month == 0:
            raise ValueError("This date has not been initialised")
        return self.__month

    def set_month(self, new_month: int):
        if not 1 <= new_month <= 12:
            raise ValueError("Month can only be set between 1 and 12")
        self.__month = new_month
    
    month = property(get_month, set_month, doc="The integer month (1-12) of this date")
d = Date()
print(d.month)    # Will raise "This date has not been initialised"
d.month = 99      # Will raise "Month can only be set between 1 and 12"

Usually, properties are used as a decorator instead of a function:

class Date:
    def __init__(self):
        self.__month = 0
    
    @property
    def month(self):
        if self.__month == 0:
            raise ValueError("This date has not been initialised")
        return self.__month

    @month.setter
    def month(self, new_month: int):
        if not 1 <= new_month <= 12:
            raise ValueError("Month can only be set between 1 and 12")
        self.__month = new_month
d = Date()
print(d.month)    # Will raise "This date has not been initialised"
d.month = 99      # Will raise "Month can only be set between 1 and 12"

Magic methods (aka dunder methods = double-underscore methods)

  • apart1 + apart2 → Apartment.__add__(self, other) → Addition
  • apart1 * apart2 → Apartment.__mul__(self, other) → Multiplication
  • apart1 == apart2 → Apartment.__eq__(self, other) → Equality test
  • str(apart) → Apartment.__str__(self) → Readable string
  • repr(apart) → Apartment.__repr__(self) → Unique string

Magic methods reading or altering attributes:

  • getattr(apart, "price")Apartment.__getattr__(self, name)
  • setattr(apart, "price", 10)Apartment.__setattr__(self, name, val)
  • delattr(apart, "price")Apartment.__delattr__(self, name)

This is why Python's duck typing does not rely on nominative types.

In [1]: dir(int)

Out[1]: 
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', 
'__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', 
 '__floor__', '__floordiv__',  '__format__', '__ge__', 
 '__getattribute__', '__getnewargs__',  '__gt__', '__hash__', '__index__', 
  '__init__', '__init_subclass__',   '__int__', '__invert__', '__le__', 
  '__lshift__', '__lt__', '__mod__',   '__mul__', '__ne__', '__neg__', 
  '__new__', '__or__', '__pos__',   '__pow__', '__radd__', '__rand__', 
  '__rdivmod__', '__reduce__',   '__reduce_ex__', '__repr__', 
  '__rfloordiv__', '__rlshift__',   '__rmod__', '__rmul__', '__ror__', 
  '__round__', '__rpow__',   '__rrshift__', '__rshift__', 
  '__rsub__', '__rtruediv__',   '__rxor__', '__setattr__', 
  '__sizeof__', '__str__', '__sub__',   '__subclasshook__', '__truediv__', 
   '__trunc__', '__xor__',    'as_integer_ratio', 'bit_length', 
   'conjugate', 'denominator',    'from_bytes', 'imag', 'numerator', 
   'real', 'to_bytes']

Back to the exercise...

  • 1.5. Using SomeClass.print() is unadvised, since the magic __str__ is made for string conversion: replace your bankAccount.print() by the magic.
  • 1.6. Implement a property with only a getter for the balance ; and a property having both a getter and a setter for the owner.

Inheritance

A furnished apartment is the same as an Apartment. But with additional furniture.

class FurnishedApartement(Apartment):   # The same as an Apartment...
   def __init__(self, price):
	   self.furnitures = ["bed", "sofa"]  # ...but with furniture	
	   super().__init__(price)


furnished_apart = FurnishedApartment(90000)
furnished_apart.available = False
furnished_apart.reduce_price(5)
furnished_apart.furnitures.append("table")

The super() function allows to call the same method in the parent class.

Note: Former Pythons require a longer syntax: super(CurrentClassName, self)

Exercise part 2: The blocked account

Bob is currently overdrawn. To prevent this kind of situation, its customer adviser prefers to convert his account into a blocked account. This way, any purchase would be refused if Bob had not enough money.

  • 2.1. Create the InsufficientBalance exception type inheriting from ValueError

  • 2.2. Implement a class BlockedBankAccount so that:

    • the BlockedBankAccount inherits from BankAccount. Make sure you do not forget to call parent method with the super() keyword if necessary
    • the transfer_to methods overrides the parent method, with the only difference that it raises InsufficientBalance if the balance is not sufficiently provided to execute the transfer
  • 2.3. Replace Bob's account by a blocked account and check that the previous scenario actually raises an exception

  • 2.4. Protect the portion of code that looks coherent with try..except in order to catch the exception without interrupting the script

  • 2.5. Explain the concept of protected method and the role of the underscore in front of the method name ; and why it is preferable that _credit is protected

Exercise part 3: The account with agios

In real life another kind of account exists: the account whose balance can actually be negative, but it that case the owner must pay agios to his(her) bank.

The proposed rule here is that, when an account is negative after an outgoing money transfer, each day will cost $1 to the owner until the next money credit.

To do so, we need to introduce transaction dates in our simulation.

  • 3.1. Implement a class AgiosBankAccount so that:
    • the AgiosBankAccount inherits from BankAccount. Make sure you do not forget to call parent method with the super() keyword if necessary
    • the constructor of this account takes in parameter the account of the bank so that agios can be credited on their account.
    • the transfer_to method overrides the parent method:
      • it takes the transaction_date in parameter, of type datetime
        (Your IDE must warn you about unmatching method signatures: update the other classes to propagate the date to same-name methods)
      • it records the time from which the balance becomes negative. You need an additional attribute for this.
    • the _credit method overrides the method from the parent class, with the only difference that it computes the agios to be payed to the bank and transfer the money to the bank. Round agios to integer values.
  • 3.2. Move the code computing the agios in a private method named __check_for_agios, explain the concept of private method and the role of the double underscore
  • 3.3. Check your implementation with the previous scenario: After Bob has a negative balance, Alice makes him a transfer 5 days later: make sure that $5 of agios are payed by Bob to his bank.

CHAPTER 3

MODULES, PACKAGES AND LIBRARIES

Difference between modules and packages

A module is a Python file, e.g. some/folder/mymodule.py. The module name uses the dotted notation to mirror the file hierarchy: some.folder.mymodule

Either the module is made to be:

  • executed from a shell: it is a script: python some/folder/mymodule.py
  • imported from another module: import mymodule (need to be installed in sys.path, see p55)

A Python package is a folder containing modules and optional sub-packages: some is a package, folder is a sub-package.

Scripts : the shebangs

On UNIX OSes a shebang is a header of a Python script that tells the system shell which interpreter is to be called to execute this Python module.

Invoke the env command to fetch the suitable interpreter for python3 with:

#!/usr/bin/env python3

Direct call to the interpreter is possible but NOT recommended, since it will force the interpreter by ignoring any virtual environment you could be in:

#!/usr/local/bin/python3

ℹ️ The Windows shell ignores shebangs, but you should provide them anyway.

Structure of Python packages

  • Packages and sub-packages allow to bring a hierarchy to your code
  • The package's hierarchy is inherited from the files-and-folders hierarchy
  • Modules hold resources that can be imported later on, e.g.:
    • Constants
    • Classes
    • Functions...
  • All packages and sub-packages must contain an __init__.py file each
  • In general __init__.py is empty but may contain code to be executed at import time

Then the package or subpackages can be imported:

import my_math.trigo
my_math.trigo.sin.sinus(0)
import my_math.trigo.sin as my_sin
my_sin.sinus(0)

Specific resources can also be imported:

from my_math.matrix.complex.arithmetic import product
sixteen = product(4, 4)

Relative imports (Imports internal to a package)

Relative import from the same folder:

from .my_math import my_sqrt
value = my_sqrt(25)

Relative import from a parent folder:

from ..my_math import my_sqrt
value = my_sqrt(25)
  • Do not put any slash such as import ../my_math
  • Relative imports can fetch . (same dir), .. (parent), ... (parent of parent)
  • Relative imports are forbidden when run from a module outside a package
  • Using absolute imports instead of relatives could result in name collisions

The Python path

The interpreter seeks for absolute import statements in the Python path sys.path.

This is a regular Python list and it can be modified at runtime (with append) to add paths to your libs.

The Python Package Index (PyPI.org)

pypi.org is a global server that allows to find, install and share Python projects.

pypi.org is operated by the Python Packaging Authority (PyPA): a working group from the Python Software Foundation (PSF).

The command-line tool Package Installer for Python (pip) can be used to install packages by their name, e.g. bottle. It can install from various sources (Link to code repos, ZIP file, local server...) and seeks on PyPI if no source is given:

pip install git+https://gitlab.com/bottlepy/bottle
pip install https://gitlab.com/bottlepy/bottle/archive/refs/heads/master.zip
pip install path/to/my/python/package/folder/
pip install path/to/my/python/package/zip/file.zip
pip install numpy    # Will seek on PyPI
pip install numpy==1.21.5   # Force a specific version
pip uninstall numpy

Non-installable Python projects usually have a file requirements.txt at their root

# requirements.txt
redis==3.2.0
Flask
celery>=4.2.1
pytest

pip has the following options:

  • pip install -r requirements.txt to install all dependencies form the file
  • pip freeze > requirements.txt to create a file of frozen versions

💡 installable packages have no such file but specify dependencies elsewhere (e.g. in pyproject.toml for installable packages using setuptools).

PyPI Security warning 🚨

PyPI packages caught stealing credit card numbers & Discord tokens

Perform sanity checks before installing a package

  • Is the package still maintained and documented?
Last update: November, 2017
  • Does the developer consider bugs and improvements?
# of solved GitLab issues
  • Is the package developer reliable?
Moral entity or individual, which company, experience...
  • If not opensource, is the development of this package likely to continue?
# of opensource users, # of clients, company financial health if not opensource, ...

PyPI Typosquatting warning 🚨

pip install -r requirements.txt
# 🚨 pip install requirements.txt

pip install rabbitmq
# 🚨 pip install rabitmq

pip install matplotlib
# 🚨 pip install matploltib

Exercise part 4: The account package

We have just implemented a very simple tool simulating transactions between bank accounts in Object-Oriented Programming.

In order to use it with a lot of other scenarii and actors, we are going to structure our code within a Python package.

We will organise our accounts with the following terminology:

  • bank-internal accounts do not create agios and are not blocked, there are BankAccount and only banks can own such account
  • bank-external accounts are for individuals or companies, they can be either blocked or agios accounts.

We would like to be able to import the classes from than manner:

from account.external.agios import AgiosBankAccount
from account.external.blocked import BlockedBankAccount
from account.internal import BankAccount
  • 4.1. Re-organize your code in order to create this hierarchy of empty .py files first as on the figure.
    Create an empty script scenario1.pyfor the scenario.
  • 4.2. Move the class declaration of AgiosBankAccount in agios.py

  • 4.3. Move the class declaration of BlockedBankAccount in blocked.py

  • 4.4. Move the class declaration of BankAccount in internal.py

  • 4.5. Move the scenario (i.e. the successive instanciation of all accounts of companies and individuals) in scenario1.py

  • 4.6. Check each module and add missing relative import statements

  • 4.7. Check each module and add missing absolute import statements

  • 4.8. Add empty __init__.py files to all directories of the package.

  • 4.9. Execute the scenario and check that it leads to the same result as before.

Outcome: Valid package but not installable yet.

  • 4.10. Make your package installable with a pyproject.toml: Refer to the doc about package creation. Move your code into a new src/ directory as recommanded by the doc.

  • 4.11. Install the project into the current venv with pip install . and make sure that you can now import account from any location and any module.

Outcome: Valid installable package.

Testing

pytest and unittest are frequently used to test Python apps

Regular stages of a single unit test:

  • Setup: Prepare every prerequisite for the test
  • Call: call the tested function with input parameters setuped before
  • Assertion: an assert is a ground truth that must be true
  • Tear down: Cleanup everything that has been created for this test

On top of these :

  • hypothesis generate representative property-based test data
  • tox runs tests in multiple envs (e.g. Python versions, numpy versions ...)

Test files are sometimes placed in a tests/ directory, file names are prefixed with test_*.py and test function names are also prefixed with test_

pyproject.toml
mypkg/
    __init__.py
    app.py
    view.py
tests/
    test_app.py
    test_view.py
    ...

Naming tests according to these conventions will allow auto-discovery of tests by the test tool: it will go through all directories and subdirectories looking for tests to execute.

class WaterTank:                          # water_tank.py
    def __init__(self):
        self.level = 10
    def pour_into(self, recipient_tank: "WaterTank", quantity: int):
        self.level -= quantity
        recipient_tank.level += quantity
from water_tank import WaterTank          # test_water_tank.py
def test_water_transfer():                # Example-based testing
    a = WaterTank()
    b = WaterTank()
    a.pour_into(b, 6)
    assert a.level == 4 and b.level == 16 
@given(st.integers(min_value=0, max_value=1000))
def test_water_transfer(quantity):        # test_water_tank_hypothesis.py
    a = WaterTank()                       # Property-based testing
    b = WaterTank()
    a.pour_into(b, quantity)
    assert a.level == 10 - quantity and b.level == 10 + quantity

Exercise part 5: Test your package

  • 5.1. Install pytest with pip
  • 5.2. Create independant test files tests/<module>.py for each module
  • 5.3. using the pytest doc, implement some example-based unit tests for your classes and run the tests with pytest
  • 5.4. Using the hypothesis doc, implement some property-based unit tests
  • 5.5. Using the tox basic example, create a tox.ini to automate test execution in Python 3.12 + 3.13.

Outcome: You have tested your package against several Python environments

Decorators and closures

The role of a decorator is to alter the behaviour of the function that follows with no need to modify the implementation to the function itself.

It can be seen as adding "options" to a function, in the form of a wrapper code.

@decorator
def function():
    pass

In that case the call of function() will be equivalent to decorator(function()).

Decorators can take parameters in input, independant from parameters of the function.

🐍 Learn more

Example 1: @classmethod is a decorator that passes the class type cls passed as the first parameter to the following function.

class Animal:
    @classmethod
    def define(cls):
        return "An " + str(cls) + " is an organism in the biological kingdom Animalia."

Example 2: Web frameworks usually use decorators to associate a function e.g. get_bookings_list() to an endpoint e.g. + a HTTP verb

Here is how Flask works:

app = Flask()   # We create a web app

@app.route("/bookings/list", method="GET")
def get_bookings_list():
    return "<ul><li>Booking A</li><li>Booking B</li></ul>"

To define your own decorator, you need to write a function returning a function:

from functools import wraps

def log_this(f):
    @wraps(f)
    def __wrapper_function(*args, **kwargs):
        print("Call with params", args, kwargs)
        return f(*args, **kwargs)
    return __wrapper_function
@log_this
def mean(a, b, round=False):
    m = (a + b)/2
    return int(m) if round else m
mean(5, 15, round=True) # shows: Call with params (5, 15) {'round': True}

The functools module is a set of decorators intended to alter functions.

An inner function is a function in a block, made to limit its scope (encapsulation)
A closure is an inner function returning a function with captured variables.

# Example: Compute a price with a base amount + variable daily rate
def get_rate_calculator(base_amount):
    current_daily_rate = some_network_callback()
    # current_daily_rate and base_amount are captured

    def calculate_rate(amount):
        return amount*current_daily_rate + base_amount

    return calculate_rate

compute_amount = get_rate_calculator(100)  # capture now!

house1price = compute_amount(150000)
house2price = compute_amount(90000)

Benefits: Sort of caching, only one network call

Exercise part 6: decorators

6.1. In a new module account.monitor, implement a @monitor decorator for the transfer_to methods to print a warning if this user has never transferred an amount of money higher than value before.

  • Recall that it is possible to assign an attribute to a function
  • Recall that a decorator takes a function in input and returns a function
  • Recall that decorators are not bound to class instances