Production-ready Python

Romeo Agbor Peter - Jan 3 '21 - - Dev Community

Below are 5 steps to take to build production-ready python applications.

1. Use Virtual Environment to Isolate Program.

The development environment in most cases is different from the production environment. For instance, the development environment for a program will be the programmer's machine (laptop, desktop or tablet). The production environment will be a virtual machine instance (AWS instance, Azure Virtual Machine, or Linode Standard) or a containerized instance(Docker, Kubernetes). To isolate the program in both environments with its Python version and modules, use the Python Virtual Environment in both environments.

The example below shows how to create and activate an isolated environment

# Create virtual environment.
> python -m venv ./env

# Activate virtual environment on Windows
> ./env/script/activate.bat && ./env/scripts/activate

# Activate virtual environment on Mac
.env/bin/activate
Enter fullscreen mode Exit fullscreen mode

Furthermore, required Python packages(with their required versions) can be installed and pushed to a file that'll hold the packages for the program.

# Install the Scrapy module via pip
(env) > pip install scrapy===2.4.1

# Push package to file
(env) > pip freeze > requirement.txt
Enter fullscreen mode Exit fullscreen mode

The example above installs a specific package with the required version. The package is then pushed to a requirement.txt file using the pip freeze command. The requirement.txt file is used for specifying the Python packages the program requires, while the pip freeze command is used for outputting installed packages names with their correct version.

In a different environment, the virtual environment can be recreated, and the required packages with the required versions specified in the requirement.txt file can be installed. Installing the packages on a Mac is quite easy as it only requires running the pip install command with the -r switch on the requirement.txt file. But on windows, the packages have to installed by copying them from the requirement file list.

# Installing required packages on a Mac
(env)> pip intalled -r requirement.txt

# Installing required packages on a Windows
(env)> pip install scrapy===2.4.1
Enter fullscreen mode Exit fullscreen mode

2. Use Config Files for Defining Deployment Environment.

The deployment environment for an app has to maintain proper configuration for the app to work correctly. The environment is where the app is run either in development or production. An app is said to be in production when it leaves the development stage.

The configuration setting of the production environment is different from that of the development environment. To modify a program on the production server, you'd have to run it on the development environment. The new changes are pushed to production.

The issue with these environments is the differences in configurations. The production environment requires different settings that may be hard to reproduce in the development environment. For instance, you have a program running on a web server with access to external data via API. To modify the code, you'd have to start the webserver container, set up API configuration and the appropriate keys needed to access the external data. These steps are unnecessary and time-consuming if all you want to do is modify a part of your program, then ensure everything works correctly.

A workaround to the above scenario is to modify your program at startup time to provide different functionalities depending on the deployment environment. Have a dedicated configuration for the program on both the development and production environment

# settings.py

# Don't run with 'TESTING' turned on in production
TESTING = True

# API credentials should be kept secrete in production
API_CREDENTIAL = {
    "consumer_key": "XXXXXXXXXXXXXX",
    "consumer_secret": "XXXXXXXXXXXXXX",
    "access_token": "XXXXXXXXXXXXXX",
    "access_secret": "XXXXXXXXXXXXXX",
}

Enter fullscreen mode Exit fullscreen mode

The TESTING constant is set to the Boolean value of True by default. It determines how the app runs on development and production. The API_CREDENTIAL constant is a key-value pair of the required API key.

Depending on the value of the TESTING variable, other modules in the app can import the settings.py file and determine how they implement their attributes.

# main.py

import settings

from typing import List


class TestingAPI:
    """
    Use mock data in development
    """

    # ...

class RealAPI:
    """
    Uses real data via API in production
    """

    def __init__(self, api_credentials: dict) -> None:
        # ...

    # ...


if settings.TESTING:
    api = TestingAPI
else:
    api = RealAPI(settings.API_CREDENTIALS)
Enter fullscreen mode Exit fullscreen mode

With the above example, modules should be customized to perform differently in deployment environments. Doing so makes it easy to skip the unnecessary reproduction code like API or database connection when it's not needed. Mock API data can be generated and injected into the program when testing or developing.

Another similar instance might be to make the app work differently based OS. Say the host server used in production is a different OS, say Linux and the one for development is a Windows, this might break the app because of the differences in OS type.

The Python sys module should be used to inspect the OS and determine its type.

# main.py

import sys

class LinuxEnv:
    # ...

class WindowsEnv:
    # ...

if sys.platform.startswith('linux'):
    config = LinuxEnv
elif sys.platform.startwith('wind32'):
    config = WindowEnv
else:
    # ...
Enter fullscreen mode Exit fullscreen mode

3. Debug Using the repr Built-in Function

Basic debugging in Python is done using the print function. The print function returns a human-readable string version of whatever argument passed to it. If there is an error on a python program, the print function can be used to output how the state of the program changes while it runs to see where the error occurred.

The issue with this way of debugging is that the function can only output human-readable string version of the value supplied to it. It doesn't output what the type of the value is.

For instance, the print function output below doesn't make clear if the value type is a Number or a String.

# Number
print(1024)

# String
print("1024")

# Human-readable output
>>>
1024
1024
Enter fullscreen mode Exit fullscreen mode

The print function is for outputting human-readable values. This doesn't help at all when debugging.

Python provides the repr built-in function to return a printable representation of an object. It can be used in conjunction with the print statement know and ensure value types when debugging.

# Number
print(repr(1024))

# String
print(repr("1024"))

# printable representation output
>>>
1024 # number
'1024' # string
Enter fullscreen mode Exit fullscreen mode

The same result is reached employing the C-style %r format string, and the % operator.

# Number
print("%r" %1024)

# String
print("%r" %"1024")

# printable representation output
>>>
1024 # number
'1024' # string
Enter fullscreen mode Exit fullscreen mode

Debugging Dynamic Objects

When debugging dynamic objects, using the human-readable function gives the same value as the repr function. That means the print function can be used because using repr on Object instances isn't helpful.

class Person(object):
    def __init__(self, name, age, height):
        self.name = name
        self.age = age
        self.height = height

obj = Person('John', 25, "6'5")

print(obj)
repr(obj)

>>>
<__main__.Persson object at 0x0000011671395160> # human-readable string
'<__main__.Persson object at 0x0000011671395160>' # object representation
Enter fullscreen mode Exit fullscreen mode

The are two ways to resolve this problem:

  • Use the __repr__ special method.
  • Use object instance dictionary when you don't control the class.

The __repr__ special method can only be used in classes that you control. It should define and return a string expression of the created object.

class Person(object):
    def __init__(self, name, age, height):
        self.name = name
        self.age = age
        self.height = height

    def __repr__(self):
        return f'Person({self.name}, {self.age}, {self.height})'

obj = Person('John', 25, "6'5")

repr(obj)

>>>
"Person(John, 25, 6'5)"
Enter fullscreen mode Exit fullscreen mode

When you don't have control over the class, use the __dict__ special attribute to get access to the object instance dictionary. The __dict__ attribute returns a dictionary of class attributes and methods.

class Person(object):
    def __init__(self, name, age, height):
        self.name = name
        self.age = age
        self.height = height

    def __repr__(self):
        return f'Person({self.name}, {self.age}, {self.height})'

obj = Person('John', 25, "6'5")

print(obj__dict__)

>>>
{'name': 'John', 'age': 25, 'height': "6'5"}
Enter fullscreen mode Exit fullscreen mode

4. Use Reusable Components

Write functions or classes that will be reused in other parts of the program to create a flow. For instance, A program that reads data from a data source(API, database, AWS s3), loads model from a pickle file, uses the model to generate predictions based on the dataset, and save the predictions in a database.

To achieve the instance above, the code responsible for handling the process could be divided into components rather than using a single function or class. Each component implies a different process that can be assembled with other components to create a pipeline for the required flow.

def read_data_from_api(args, info):
    #...
    return data

def load_model(info):
    #... 
    return model

def run_predictions(data, model):
    #...
    return predictions

def save_predictions_to_db(args, predictions):
    #...


def main:
   """Prediction pipeline"""

   data = load_data_from_api(args, info)
   model = load_model(args)
   Predictions = run_predictions(arg, data, model)
   save_predictions_to_db(args, predictions)
   #...
Enter fullscreen mode Exit fullscreen mode

Benefits of using the component approach:

  • Component can be reused in other pipelines
  • Easy to improve and modify components overtime

In the code sample above, all four components are assembled as pipeline in the mian() function.

5. Test Program with Unittest

Python is a dynamically typed programming language. That means that it doesn't have a static type checker by default. Not having a static type checker often results in runtime errors.

All programs should be tested regardless of the programming language used, but Python is specifically limited as it has no type checking, at least not by default. Fortunately, the unittest module can be used to test python programs. Python dynamism makes it easy to write to test.

Testing is ensuring good code quality. It gives the programmer assurance that the program will work as expected when deployed. The responsible programmer should always build with testing in mind.

To use the built-in unittest module on your code, it has to be imported in a different python file. For instance, say you have a utility function defined in utils.py.

# utils.py

def to_str(data):
    if isinstance(data, str):
        return data
    elif isinstance(data, bytes):
        return data.decode("utf-8")
    else:
        raise TypeError("Must supply string or byte. Found %r" %data)
Enter fullscreen mode Exit fullscreen mode

To perform a test, you need to create a second file with the word test followed by the name of the file you want to test, which is the utils.py file. So the name of the file will be test.utils.py.

#test_utils.py

from unittest import TestCase, main
from utils import to_str

class UtilsTestCase(TestCase):
    def test_to_str_bytes(self):
        # Verifies equality
        self.assertEqual("hello", to_str(bhello))

    def test_to_str_str(self):
        # Verifies boolean expression
        self.assertEqual("hello", to_str(hello))

    def test_to_str_bad(self):
        #verifies exception is raised
        self.assertRaises(TypeError, to_str, object())

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Each test method begins with the word test. If a test method runs without raising any exception, then the test is successful. The tests above are organized according to test cases in the TestCase subclasses. The subclasses include helper methods for making assertion tests, such as assertEqual, assertNotEqual, assertRaises, and assertTrue.

NOTE:

To learn more about the testing and the unittest module, Click the link to the python documentation on testing. https://docs.python.org/3/library/unittest.html

Conclusion

So there you have it, 5 guidelines on building production-ready python apps. Don't forget to check out the full article to see all 7 steps.

. . . . . . . . . . . . . . . . . . .