How to use constants in Python applications
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 packages,
- 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
Applicationclass takes one required parameter,version, versionis implemented as a read-only property ofApplication. It can have any valid integer value,__str__method returns the class name as well as theversionproperty value. This method will be helpful in examining the state ofApplicationinstances 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 - thenpattern, - we are using a
unittest.mock.Mockobject to mock theversionvalue.
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 theversionvariable, application = Application(version)creates a newApplicationinstance withversionvalue supplied as parameter,- as we have a
__str__defined inApplicationclass,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
Naturally, 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: 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:
versionattribute possible values are defined in a set{1, 2, 3},- on an
Applicationinstance creation, we check if the givenversionvalue belongs to the set of the possible values. If not, anAttributeErroris 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
versionvalue from the list of possible values, - we use
pytest.raisesfunction to check ifAttributeErroris raised on attempt of a newApplicationinstance creation with an incorrectversionvalue, - there are two new imports, of
randomandpytestmodules.
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
tryblock we have a normal scenario of the code execution. Just like the one we had previously, - in case in incorrect
versionvalue is provided and anAttributeErroris risen, such error is caught by theexceptclause. An error message is printed out in case of an error, - no matter whether an
AttributeErroris 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:
versionattribute possible values are moved to theApplicationclass attribute,- the
VERSIONSclass attribute name is written in all capital letters, signifying that its value is a constant, - the
VERSIONSis a set to make sure that each specific version is unique, - hardcoded lists of
versionvalues are replaced by calls of theVERSIONSclass 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.choiceargument, 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
versionvalue 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 place. 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 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
ConfigurationErrorclass is defined for errors which may happen during configuration, - configuration file parsing is located in the
parse_configuration_filefunction 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_VERSIONSin 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 previously definedConfigurationErroris raised, - obtained configuration data is assigned to the
configurationvariable. This variable will be available for importing using the syntax:<package_name>.configurationand, most importantly, it will be valorised only once, during the package first time loading, - in order to avoid the
configuration.yamlfile discovery when usingApplicationclass from other modules in other packages and in tests, we get the path to theconfiguration.yamlfile via the absolute path of the current__init__.pyfile. We expect theconfiguration.yamlfile 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 significant 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 aversionparameter of typeApplicationVersioninstead ofint. AsApplicationVersionhas a defined number of possible values, in__init__method we do not need to check if the providedversionvalue belongs to the possible values set anymore. The providedApplicationVersionvalue is, a priori, correct. versionproperty code has also been modified. Asversionis of typeApplicationVersion, we need to convert it to integer before returning to caller. We do it by getting theApplicationVersionelement value. For example, for an element with nameV1the 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 simpler.
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
ApplicationVersionelement to supply as theApplicationclassversionargument, - in the assert statement, we need to convert the
ApplicationVersionelement tointby 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: [email protected].
