Python Intermediate Topics

Object Oriented Programming, Custom Exceptions, and Best Practices.


Object Oriented Programming

Object Oriented Programming (OOP) is all about the idea of Encapsulation.

Encapsulation is a way to find related properties and behaviours in our program and create a blueprint out of them which we can use later.


Example

A scenario could be a program about managing the employees of a company. Each employee has some properties like name, age, and role. Some employees also have properties such as insurance and stock options plan.

There also some behaviours that each employee has, such as asking for a raise, PTO, or resign.


TRY IT YOURSELF

How would you go about writing this program with the knowledge you have so far? What are the limitations?

Hint: You can use Python dictionary to represent a person.


OOP Has Benefits

This is in Python, especifically, although most of them are language agnostic.

  • DRY
  • Enforce objects to have some properties and behaviours
  • Simply working with complex data types like dictionaries and lists
  • Extensible
  • More readable
  • Namespaces
  • Inheritance

Classes

A Python class is a blueprint from which you can create instances (or objects). It contains the properties (fields) and behaviours (methods) you want your objects to have.

class ClassName:
    # define properties and methods

Best Practice: Class names should be in CapitalCase.


__init__() Method

The __init__() method in a class is for defining and initializing properties that each object will get once initialized.

class Employee:
    def __init__(self, name, age):
        self.name = name
        self.age = age

Like any other functions, class methods can accept arguments from the callers.


self

self represents the instance of a class, or the current object. Having self as the first parameter of class methods is a way to access the objects' properties

self is passed to class methods implicitly! That is, when calling class methods, we don't pass self.


Creating an Object

You can create an object off of a class by using the class name and passing the required arguments in the __init__() method.

employee_1 = Employee("Ross Geller", 30)
employee_2 = Employee("Rachel Green", 30)

Note that we're not passing anything for self. That's passed to the method by Python itself.

Properties and methods can be accessed using the dot (.) syntaxt.

print(employee_1.name)

Adding Methods

Methods are behaviours you want your objects to have. They are basically functions with the first argument being the object itself (self).

def submit_pto(self, hours):
    print(f"{self.name} is requesting PTO for {hours} hours")

You can call the function using an object of the class and the dot . syntax.

employee_1.submit_pto(8)

Inheritance

You can create a class that inherits its properties and methods from another one and has some properties or methods on its own too.

class Cat(Animal):
    # class Cat is inheriting from class Animal

TRY IT YOURSELF

Explain how OOP can offer the following benefits over the traditional (non-OOP) approach.

  • DRY
  • Enforce objects to have some properties and behaviours
  • Simply working with complex data types like dictionaries and lists
  • Extensible
  • More readable
  • Namespaces
  • Inheritance

Testing

Testing your code is important because that's the only way you can be confident your code is going to work before merging it to one of the main branches.

Basically, with tests, you give your code a go before your end users do. This helps you find potential problems before your users experience them in production.


Types of Testing

There are different types of testing. Here are the most popular ones (there are more):

  • Unit Testing
  • Integration Testing
  • End-to-End Testing

Unit Testing

Unit testing is a testing method focused around testing individual "units", or pieces of code.

The primary goal of unit testing is to make sure that a piece of code does what it’s supposed to.

The piece of code under test is usually a function.

We'll be talking about this type of testing in this course.


Integration Testing

Integration testing is a testing method focused on testing multiple components together.

The primary goal of integration testing is to make sure different components of software work together as expected.


End-to-End Testing (E2E)

System tests, or end-to-end (E2E) tests, are focused on vetting the behavior of a system from end-to-end.

The primary goal of end-to-end testing is to ensure the entire application or system as a whole behaves how we expect it to.


pytest

There are a number of third-party packages to unit test your Python code, including pytest, unittest, and testify. However, pytest is the most popular one and is the library we're going to use in this course.

pytest is not part of the Python standard library and needs to be installed before used. You can install it using pip install pytest.


Test Files

We write our unit tests in separate files. pytest requires the name of the test files to start with test_. Otherwise, it won't pick them up as files containing unit tests.


Unit Tests

Unit tests go into test files (files starting with test_). Unit tests are Python functions testing a piece of code (usually functions) in application code.

pytest requires the name of the unit test functions to start with test_. Otherwise, it won't run them.


Example of a Test File

Assuming we have a function named add(a, b) in a file named main.py, here's a test file to perform unit testing:

test_main.py

from main import add

def test_add():
    a, b = 5, 10
    result = add(a, b)
    assert result == 15

assert

We use assertions to verify the logic of the piece of code we're testing. An assertion is a claim about a condition. We use the assert keyword to make an assertion.

Example: by assert result = 15 we're claiming that the value of result should be 15. If it's not, then the assertion is not correct and the unit test fails.


Commonly Used Assertions

AssertionClaim
assert a == bAssert that two values are equal.
assert a != bAssert that two values are not equal.
assert aAssert that a is True
assert not aAssert that a is False
assert item in listAssert that item is in a list
assert item not in listAssert that item is not in a list

Running Tests

Once we have our test files and unit tests, we can run them using the pytest command. pytest will search the current directory (and its subdirectories) for files starting with test_. Then it will search for functions starting with test_ inside the files and run them.


pytest Cheat Sheet

  • pytest: will run pytest.
  • pytest -k "substring": will only run tests that contain substring in their names.
  • pytest -s: will show program outputs such as prints.
  • pytest -l: will show the value of local variables in case of an exception.
  • pytest -v: will show verbose results (results with more information).

Linting

Linting your code has a number benefits:

  1. Helps identify errors
  2. Enforces coding standards
  3. Improves code quality by suggesting best practices

Python Linters

There are several third-party linters that you can use for linting your Python code. Here are a few:

  • pylint
  • pytype
  • Ruff

PyLint is the most popular one.


Using pylint

Like pytest, pylint is an external library that doesn't come with the Python standard library; hence, you need to install it first: pip install pylint.


Running pylint

You can run pylint using the pylint directory-name command. This will run pylint on all the Python files in tha directory.


Formatting Your Code

Although Python has an implicit formatting style (using indentation), there are still different ways to write Python code. Here's one example:

# one way
person = {"first_name": "Steve", "last_name": "Jobs", "is_alive": False}
# another way
person = {
    "first_name": "Steve", 
    "last_name": "Jobs", 
    "is_alive": False
    }

Using a Formatter

When working in a team, using a formatter helps a lot. Without a formatter, each developer could have a different coding style, which makes reviewing and reading each other's code difficult. Therefore, developers working on the same project are recommended to use a formatter to avoid such difficulties.


Python Formatters

There are several third-party formatters for Python. Here are a few:

  • black
  • isort
  • autopep8

black is the most popular one. Current companies use black: Facebook, Dropbox, Lyft, Mozilla, Quora, Duolingo, Tesla.


Using black

Black is a third-party library that doesn't come with Python pre-installed. So, we need to install it first: pip install black.

Once installed, we can use the following command to format our code:

black source_file_or_directory

Using black in VSCode

You can also set black to be the default formatter of Python code in VSCode and run it each time you save your code.

Read more here.


Python Best Practices

A short list of best practices you're recommended to follow when writing Python programs.


The __name__ == "__main__" Expression

You're already familiar with Python modules (separate files with functions that you can import into other files). By using the __name__ == "__main__" you're letting the users of your modules to only run the scripts they want to and nothing else.

if __name__ == "__main__":
    # some code

Order of imports

Try to have this order for your imports:

  1. Standard libraries (such as json)
  2. Third-party libraries (such as requests)
  3. Your own modules (such as math_util that we created earlier)

Prefer Multiple Assignment Over Indexing

Python lets you unpack complex types like lists and tuples. Unpacking should be preferred because it makes code more readable.

area_perimeter = [16, 15]

# bad
area = area_perimeter[0]
perimeter = area_perimeter[1]

# good
area, perimeter = area_perimeter

Prefer enumerate over range in Loops

When you have a list of items, you can loop through the list and get each item plus the index of the item using range:

my_list = ["Calgary", "Toronto", "Edmonton"]
for idx in range(len(my_list)):
    print(f"city number {idx} is {my_list[idx]}")

You can achieve the same thing with enumerate which gives you the next item and the index.

my_list = ["Calgary", "Toronto", "Edmonton"]
for idx, city in enumerate(my_list):
    print(f"city number {idx} is {city}")

Use zip to Iterate Multiple Lists in Parallel

Sometimes, you have separate lists and want to loop over them at the same time and access items from both.

provinces = ["Alberta", "Ontario", "British Columbia"]
capitals = ["Edmonton", "Toronto", "Victoria"]

for province, capital in zip(provinces, capitals):
    print(f"The capital of {provice} is {capital}.")

Gotcha: it only goes as far as the smallest list! Look at itertools.zip_longest().


Know How to Use Assignment Expression :=

Assignment Expression (:=) is a new syntax introduced in Python 3.8. The expression is most useful for doing an assignment and perform a conditional test check at the same time:

cities = ["Calgary", "Edmonton"]
# bad
first_city = cities[0]
if first_city == "Calgary":
    # do stuff

# better
if (first_city := cities[0]) == "Calgary":
    # do stuff

Use Catch-all Expression Over Slicing

You can divide a list to different sections using slicing:

cities = ["Calgary", "Edmonton", "Victoria", "Vancouver", "Waterloo", "Montreal"]
most_important = cities[0]
second_important = cities[1]
the_rest = cities[2:]

Thats is verbose and hard to read. You can use unpacking and catch-all expression (*) instead.

cities = ["Calgary", "Edmonton", "Victoria", "Vancouver", "Waterloo", "Montreal"]
most_important, second_important, *the_rest = cities

Remember Functions Are First-class Citizens

Functions in Python are first-class citizens, meaning that you can do almost everything with them as you can with variables: assing them into variables, pass them into another functions, return by a function, stores in lists, etc.

def control_unit(fn, **kwargs):
    return fn(**kwargs)

def add(a, b):
    return a + b
def subtract(a,b):
    return a - b

print(control_unit(add, a=5, b=10))
print(control_unit(subtract, a=5, b=10))

Use lambda For One-line One-time Functions

There are times that you need to pass a function as an argument to another function, but that's it! You're not going to use that function anywhere else. Python's lambda enables you to do that.

print(control_unit(lambda a,b:a+b, a=5, b=10))

Sort Based on Custom Criteria Using the key Parameter

You can use the sort or sorted with list of items that have a natural order (e.g. numbers, strings). But what about objects of a class?

class Person:
    # has 'name' and 'age' properties
person_1 = Person("Alice", 28)
person_2 = Person("John", 30)
people = [person_1, person_2]
people.sort()
# sort based on age
people.sort(key=lambda x:x.age)

Use get() to Look for a Key in a Dictionary

Using the dictionary_name["key"] to get a key from a dictionary has the potential of raising a KeyError exception, which you should handle to make sure your program doesn't crash.

Use get() instead as it doesn't raise any exceptions. You're also able to provide a default value as the second argument.

my_dict = {"name": "Ross"}
age = my_dict["age"] # exception!

age = my_dict.get("age") # returns None but no exception
age = my_dict.get("age", 0) # returns 0

Raise Exception Instead of Printing Errors

When a function runs into a situation where it can't perform properly, it's always a good idea to raise an exception and let the caller know about it, other than simply printing out or returning a message as that'd be hard to decode.

# bad
def set_age(self, age):
    if age < 0:
        return "invalid age"
    self.age = age 
    return "OK"

# better
def set_age(self, age):
    if age < 0:
        raise ValueError("invalid age")
    self.age = age 

Know Python's Built-in Exceptions

You can see the exceptions that come with the Python library here.

The popular and generic ones:

  • ValueError
  • KeyError
  • Exception

Create Custom Exception to Improve Readability

If you need to raise an exception that doesn't come with Python, you can create a new one with a descriptive name by creating a class that inherits from the Exception class:

# bad
def get_record(record_id):
    # ...
    raise ValueError("record doesn't exist")

# good
class RecordNotExist(Exception):
    pass

def get_record(record_id):
    # ...
    raise RecordNotExist(f"record {record_id} doesn't exist")

Use Enums When You Need Distinct Options Regardless of Values

Sometimes all you want is a list of distinct things. For example, you want to mark a process as COMPLETED, IN_PROGRESS, or STOPPED. As long as you can distinguish between the three states, you're good. You don't care about the values.

In cases like this, you can use inherit from the Enum class and create distinct options:

from enum import Enum
class Status(Enum):
    COMPLETED = 1
    IN_PROGRESS = "banana"
    STOPPED = False

process.status = Staus.STOPPED

Write Docstrings for Every Function, Class, and Module

You should always provide documentation about your code, whether that's a function, class, or a module.

We add docstrings using the """.

  • For modules: that goes to the first line of the file
  • For functions (and methods): the first line after the function definition.
  • For classes: the first line after the class definition.

Follow Docstring Patterns

There are certain patterns that Python developers use when it comes to docstrings. Here's an example for a function:

def find_anagrams(word, dictionary):
    """Finds all anagrams for a word.

    Args:
        word: String of the target word.
        dictionary: dictionary of words as keys and definitions as values. 

    Returns:
        List of anagrams that were found. Empty if
        none were found.
    
    Raises:
        ValueError if word is not a string.
    """

Check Documentation with pydoc

You can see run a server that shows all the Python documentation, plus your own using the pydoc library.

pydoc -p 3337


Comprehension

There's a shorter syntax in Python when you want to create a list, dictionary, or tuple based on the values of an existing list, dictionary, tuple, etc.

numbers = [1,2,3,4]
squared = []
for number in numbers:
    squared.append(number**2)
print(squared) # [1, 4, 9, 16]

# better
squared = [number**2 for number in numbers]
print(squared) # [1, 4, 9, 16]

List Comprehension

When you want to create a new list based on the values of an iterable.

Syntax:

new_list = [expression for item in iterable if condition == True]

Example:

numbers = [1,2,3,4]
even_squared = [number**2 for number in numbers if number % 2 == 0]
print(squared) # [4, 16]

Dictionary Comprehension

When you want to create a new dictionary based on keys and values of an iterable.

Syntax

new_dict = [expression for item in iterable if condition == True]

Example:

names = ["Ross", "Chandler", "Rachel", "Monica"]
people = {f"person_{idx}": person for idx, person in enumerate(names, 1)}
print(people)
# {'person_1': 'Ross', 'person_2': 'Chandler', 'person_3': 'Rachel', 'person_4': 'Monica'}

Testing & Linting

Always write unit tests for your code and use a linter to check for potential errors and areas of improvements.