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 ofApplication
. It can have any valid integer value,__str__
method returns the class name as well as theversion
property value. This method will be helpful in examining the state ofApplication
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 theversion
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 theversion
variable, application = Application(version)
creates a newApplication
instance withversion
value supplied as parameter,- as we have a
__str__
defined inApplication
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 givenversion
value belongs to the set of the possible values. If not, anAttributeError
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 ifAttributeError
is raised on attempt of a newApplication
instance creation with an incorrectversion
value, - there are two new imports, of
random
andpytest
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 anAttributeError
is risen, such error is caught by theexcept
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 theApplication
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 theVERSIONS
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 definedConfigurationError
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 usingApplication
class from other modules in other packages and in tests, we get the path to theconfiguration.yaml
file via the absolute path of the current__init__.py
file. We expect theconfiguration.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 aversion
parameter of typeApplicationVersion
instead ofint
. AsApplicationVersion
has a defined number of possible values, in__init__
method we do not need to check if the providedversion
value belongs to the possible values set anymore. The providedApplicationVersion
value is aprioiri correct. version
property code has also been modified. Asversion
is of typeApplicationVersion
, we need to convert it to integer before returning to caller. We do it by getting theApplicationVersion
element value. For exmaple, for an element with nameV1
the value will be1
.
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 theApplication
classversion
argument, - in the assert statement, we need to convert the
ApplicationVersion
element toint
by taking itsvalue
.
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: