How to use constants in Python applications

Posted September 7, 2021 by Yaroslav Grebnov ‐ 14 min read

Introduction

In this post we will discuss in detail five different examples of how to use constants in Python applications:

  • simple sets of possible values,
  • class attributes,
  • constants packges,
  • configuration files,
  • enums.

The examples are presented in the complexity increasing order, with examining of changes made in the code. In each example, we discuss benefits of the approach, as well as use cases where it can be used.

In all examples we are using an Application class which has a version property. This property can have a value from a limited pre-defined set. We examine the code of the class, the code of the unit tests, and the code of how the Application class can be used from another module. In unit tests we are using pytest and given-when-then pattern.

The code samples in this post were created and tested using Python 3.8.

Starting point

We will start from an Application entity which has a version attribute. Let us not worry about the version values for now, we will just assume that they are of type int. The Application class may look like this:

class Application:
    def __init__(self, version: int):
        self._version = version

    @property
    def version(self) -> int:
        return self._version

    def __str__(self):
        return f"Instance of {self.__class__.__name__}, version: {self.version}"

Several notes:

  • at initialization, the Application class takes one required parameter, version,
  • version is implemented as a read-only property of Application. It can have any valid integer value,
  • __str__ method returns the class name as well as the version property value. This method will be helpful in examining the state of Application instances from various parts of the code.

Let us test our implementation. In our examples, we will use pytest and unittest packages:

from unittest.mock import Mock
from entities.application import Application


def test_application_has_expected_version_value():
    # given
    mocked_version = Mock()
    # when
    application = Application(mocked_version)
    # then
    assert application.version == mocked_version

The test_application_has_expected_version_value test can be read like this: Given there is some version value, When an instance of Application is created using that version value, Then the Application instance version attribute has the value defined at the first step.

Some notes on the code:

  • we are following the given - when - then pattern,
  • we are using a unittest.mock.Mock object to mock the version value.

From some other module, the Application class can be used like this:

import random
from entities.application import Application


def main():
    version = random.choice(range(10))
    application = Application(version)
    print(application)


if __name__ == '__main__':
    main()

Notes:

  • in version = random.choice(range(10)) line we assign a random value from 0 to 9 to the version variable,
  • application = Application(version) creates a new Application instance with version value supplied as parameter,
  • as we have a __str__ defined in Application class, print(application) prints out data in the format we have configured. For example: Instance of Application, version: 4

The code above outputs the following (version value is random, so it may be different when you execute it):

Instance of Application, version: 4

Set of possible version values

Nuturally, the version attribute values set is limited. Obviously, it cannot contain negative numbers and it is limited from above. Let’s say that there are only three possible version values: {1, 2, 3}. In order to satisfy this new requirement, we need to make a change in the Application class __init__method:

def __init__(self, version: int):
    if version not in {1, 2, 3}:
        raise AttributeError("Version attribute value must be one of: 1, 2, 3")
    self._version = version

Notes:

  • version attribute possible values are defined in a set {1, 2, 3},
  • on an Application instance creation, we check if the given version value belongs to the set of the possible values. If not, an AttributeError is raised with a corresponding message.

The new requirement needs to be reflected in tests also. We will modify the existing test to check a new Application instance creation with version value belonging to the set of possible values. Additionally, we will create a new test to check raising of the AttributeError on attempt to create a new Application instance with version value which does not belong to the set of possible values.

import random
import pytest
from entities.application import Application


def test_application_has_expected_version_value():
    # given
    version = random.choice([1, 2, 3])
    # when
    application = Application(version)
    # then
    assert application.version == version


def test_application_raises_error_on_incorrect_version_value():
    # given
    version = 4
    # then
    with pytest.raises(AttributeError):
        application = Application(version)

The test_application_raises_error_on_incorrect_version_value test can be read like this: Given the version has value 4, Then an AttributeError is raised on attempt to create a new instance of Application with that version value.

Notes:

  • we do not use mocks anymore. We use actual values instead, correct ones in the first test and incorrect one in the second one,
  • in the first test, we randomly choose the version value from the list of possible values,
  • we use pytest.raises function to check if AttributeError is raised on attempt of a new Application instance creation with an incorrect version value,
  • there are two new imports, of random and pytest modules.

We also need to modify the way of using the Application class from other modules. Since the caller does not know the list of correct values, an incorrect version value can potentially be supplied, in that case the caller’s application execution will terminate with an error. In order to avoid that, the creation of an Application instance should be wrapped in a try-catch block, so that the caller can choose what to do in case of an error.

version = random.choice(range(10))
try:
    application = Application(version)
    print(application)
except AttributeError as e:
    print(f"Error creating an Application instance: {e}")

print("Continue execution")

Notes:

  • in a try block we have a normal scenario of the code execution. Just like the one we had previously,
  • in case in incorrect version value is provided and an AttributeError is risen, such error is caught by the except clause. An error message is printed out in case of an error,
  • no matter whether an AttributeError is risen or not, the code execution continues, as we can see by the “Continue execution” message which is printed out.

In case of an incorrect version value, the code outputs something like:

Error creating an Application instance: Version attribute value must be one of: 1, 2, 3
Continue execution

In case of a correct version value, the output will be like:

Instance of Application, version: 2
Continue execution

Set of possible version values in a class attribute

As you can see from the code, the possible version values are fully listed in several places. It is a bad practice to do so, as if we need to modify the list of version values, we are obliged to make changes in several places in code. It is inconvenient and can potentially lead to bugs. Let us modify the code to address the issue.

class Application:
    VERSIONS = {1, 2, 3}

    def __init__(self, version: int):
        if version not in self.VERSIONS:
            raise AttributeError(f"Version attribute value must be one of: {self.VERSIONS}")
        self._version = version

Notes:

  • version attribute possible values are moved to the Application class attribute,
  • the VERSIONS class attribute name is written in all capital letters, signifying that its value is a constant,
  • the VERSIONS is a set to make sure that each specific version is unique,
  • hardcoded lists of version values are replaced by calls of the VERSIONS class attribute.

As we can see, the version possible values is listed only once in code, in the place where it is defined. We are just referencing that definition in all other places of its usage.

Both tests will also be modified. Just like for the Application class, the lists of values are replaced by referencing of Application.VERSIONS.

def test_application_has_expected_version_value():
    # given
    version = random.choice(list(Application.VERSIONS))
    # when
    application = Application(version)
    # then
    assert application.version == version


def test_application_raises_error_on_incorrect_version_value():
    # given
    version = max(Application.VERSIONS) + 1
    # then
    with pytest.raises(AttributeError):
        application = Application(version)

Notes:

  • we cannot use a set as random.choice argument, because the method requires its argument to be ‘subscriptable’ (it should be possible to call the elements by their indices in the sequence). So, we convert the set into a list,
  • note the way of supplying an incorrect version value in the second test: version = max(Application.VERSIONS) + 1. We take the maximum possible value and add 1 to it. In this way, we obtain a value which is guaranteed not to belong to the set of the possible values.

As you can see, the modifications in the test code exclude all explicit version value mentioning. We keep the version values defined only once in the Application class.

As for using Application class from other modules, we have two options. The first one is to keep the existing generic implementation using try-catch block. The second option relies on the fact that now the caller has a possibility to know the list of the version possible values. So, before attempting to create a new Application instance, we can check whether the version value belongs to the set of the accepted values.

version = random.choice(range(10))
if version not in Application.VERSIONS:
    print("Do not attempt to create an Application with incorrect version value")
else:
    application = Application(version)
    print(application)
print("Continue execution")

In case of an incorrect version value, the code will output something like:

Do not attempt to create an Application with incorrect version value
Continue execution

Set of possible version values in a constants package

Defining constants as class attributes may be acceptable if those constants are only applicable to that class and there are just a few classes in the module. In all other cases, it is generally better to group all constants in a separate plase. It is much more convenient to use and maintain. In this example, we will group them in a new package called constants. The constants will be located in that package __init__.py file (for convenience, later in the text, we will refer to it as ‘constants’).

Since the applications versions list is no longer located in the Application class, we will rename VERSIONS variable to APPLICATION_VERSIONS. The constants file will look like this:

APPLICATION_VERSIONS = {1, 2, 3}

We will need to add constants import to the file containing Application class and replace self.VERSIONS by APPLICATION_VERSIONS.

The same modification needs to be made in the tests and in the code showing Application class usage from other modules.

Set of possible version values in a configuration file

In case we have a large complex list of constants or in case we need to have separate lists of constants for different environments (for example, one list for development, one - for testing, and one for production), we may replace the constants package by a configuration file or place the constants list into a database. This approach will reveal its additional benefits during deployment and maintenance of a dockerized application, since a different file or a database with different content can dynamically be supplied without any need of changing the code. In this example we will see how to use a configuration file in yaml format.

Before we start, we need to install the pyyaml package which will allow us to parse files in yaml format:

pip install pyyaml

Next, we create the configuration file itself. Its name will be configuration.yaml and we will place it in root of the main package.

APPLICATION_VERSIONS:
  - 1
  - 2
  - 3

In order to use data from the configuration file, it should first be parsed. To keep things simple, we will place the corresponding code into the the main package __init__.py file.

import os
import yaml


class ConfigurationError(Exception):
    pass


def parse_configuration_file(file: str) -> dict:
    with open(file) as f:
        data = yaml.load(f, yaml.FullLoader)

    if not data.get('APPLICATION_VERSIONS') or type(data.get('APPLICATION_VERSIONS')) != list:
        raise ConfigurationError("APPLICATION_VERSIONS list is absent from the configuration file")

    return data


configuration = parse_configuration_file(f'{os.path.dirname(os.path.abspath(__file__))}/configuration.yaml')

Notes:

  • a ConfigurationError class is defined for errors which may happen during configuration,
  • configuration file parsing is located in the parse_configuration_file function which takes path to the configuration yaml file as argument and returns a dictionary of configuration values,
  • we are using a context manager to open and parse the configuration file:
with open(file) as f:
    data = yaml.load(f, yaml.FullLoader)
  • we check whether the configuration data contains APPLICATION_VERSIONS in a form of a list: if not data.get('APPLICATION_VERSIONS') or type(data.get('APPLICATION_VERSIONS')) != list:. In case any of these two conditions is not satisfied, a previosly defined ConfigurationError is raised,
  • obtained configuration data is assigned to the configuration variable. This variable will be available for importing using the syntax: <package_name>.configuration and, most importantly, it will be valorised only once, during the package first time loading,
  • in order to avoid the configuration.yaml file discovery when using Application class from other modules in other packages and in tests, we get the path to the configuration.yaml file via the absolute path of the current __init__.py file. We expect the configuration.yaml file to be located in root of the main package:
f'{os.path.dirname(os.path.abspath(__file__))}/configuration.yaml'

os.path.dirname takes the directory of the current file absolute path (os.path.abspath(__file__)) and adds configuration.yaml to it.

The changes in the Application class definition file are minimal. We add an import of configuration and replace APPLICATION_VERSIONS constant by APPLICATION_VERSIONS value of the configuration dictionary (non-modified file part is not present in the listing below):

import configuration


class Application:
    def __init__(self, version: int):
        if version not in configuration.get("APPLICATION_VERSIONS"):
            raise AttributeError(
                f"Version attribute value must be one of: {configuration.get('APPLICATION_VERSIONS')}"
            )
        self._version = version

Identical changes are made to the tests and code showing the Application class usage from other modules. We would like to note that since the configuration is an internal entity of the package which takes values from an external configuration.yaml file, in other packages it would be better to use Application class with a try-catch block.

Set of possible version values in an enum

Enums in Python are powerful constructions allowing to write compact, but functionally rich code. In cases of configuration settings which depend on each other and when we need to perform manipulations with groups of those settings, enums is a much better choice than constants packages or configuration files. We discuss advantages of using enums in more details in a separate post.

An enum in Python is a class inheriting from enum.Enum. We will create an ApplicationVersion enum and place it in a file in enums package:

from enum import Enum


class ApplicationVersion(Enum):
    V1 = 1
    V2 = 2
    V3 = 3

Notes:

  • each enum element is an object which has a name and a value: in V1 = 1, ‘V1’ is a name and ‘1’ is a value,
  • as the names are technically class attributes, they follow the Python rules for naming the class attributes. So, for example, they cannot start from a number.

We have signifacant changes in the Application class. The changes are caused by modifying the type of the version from int to ApplicationVersion.

from enums.application_version import ApplicationVersion


class Application:
    def __init__(self, version: ApplicationVersion):
        self._version = version

    @property
    def version(self) -> int:
        return self._version.value

Notes:

  • as we have mentioned earlier, __init__ method takes a version parameter of type ApplicationVersion instead of int. As ApplicationVersion has a defined number of possible values, in __init__ method we do not need to check if the provided version value belongs to the possible values set anymore. The provided ApplicationVersion value is aprioiri correct.
  • version property code has also been modified. As version is of type ApplicationVersion, we need to convert it to integer before returning to caller. We do it by getting the ApplicationVersion element value. For exmaple, for an element with name V1 the value will be 1.

The tests have also been modified. As we cannot possibly provide an incorrect version value of type ApplicationVersion, we do not need neither to check whether the version value is correct, nor use a try-catch block. As in the Application class, the code is much simplier.

import random
from entities.application import Application
from enums.application_version import ApplicationVersion


def test_application_has_expected_version_value():
    # given
    version = random.choice(list(ApplicationVersion))
    # when
    application = Application(version)
    # then
    assert application.version == version.value

Notes:

  • in the first test, we just randomly select an ApplicationVersion element to supply as the Application class version argument,
  • in the assert statement, we need to convert the ApplicationVersion element to int by taking its value.

An important note, as the Application class do not throw any exception on initialization, we do not need the second test anymore. This is one more benefit of using enums.

The changes in the code of Application class using from other modules are similar to the changes in tests. We do not need neither the version value correctness check, nor the try-catch block anymore.

import random
from entities.application import Application
from enums.application_version import ApplicationVersion


def main():
    version = random.choice(list(ApplicationVersion))
    # or, for example, version = ApplicationVersion.V1
    application = Application(version)
    print(application)

Conclusion

We have discussed in detail five examples of how constants can be used in a Python application. We started from the most simple one, just a set of values, and continued with using class attributes, constants package, and finished with the more advanced ones, configuration files and enums. In each example, we explained advantages and disadvantages of the approach, as well as the use cases where each approach can be most appropriate.

In case you have a question or a comment concerning this post, please send them to: