Using figenv for configuration objects

figenv provides a metaclass that makes smart decisions about variables to cast them to the correct type when they are accessed on a configuration object. It is a drop-in replacement for libraries like Flask-Env, but instead of setting the attributes at import time, the values are pulled from the environment at access time, allowing for patching multiple attributes in the test suite with one patch.dict() request to os.environ.

figenv also allows for setting attributes as functions, so that the user can take values that are broken up into multiple parts and construct them into a single value.

You do not need to instantiate the Config class with the figenv.MetaConfig object because the attributes are set by the metaclass and are all classmethods.

Lets say you have the following

class Config:
    DATABASE_URL = 'postgresql://username:password@localhost:5432/dbname'

This parameter can be converted to be able to be constructed from a list of attributes.

class Config(metaclass=figenv.MetaConfig):
    POSTGRES_MAIN_HOST = 'localhost'
    POSTGRES_MAIN_DATABASE = 'dbname'
    POSTGRES_MAIN_PASSWORD = 'password'
    POSTGRES_MAIN_USER = 'username'
    POSTGRES_MAIN_PORT = 5432

    def DATABASE_URL(cls):
        return 'postgresql://{user}:{password}@{host}:{port}/{db}?sslmode=prefer'.format(
            user=cls.POSTGRES_MAIN_USER,
            password=cls.POSTGRES_MAIN_PASSWORD,
            host=cls.POSTGRES_MAIN_HOST,
            port=cls.POSTGRES_MAIN_PORT,
            db=cls.POSTGRES_MAIN_DATABASE,
        )

Now, if Config.DATABASE_URL is requested, first the figenv metaclass functions will check the DATABASE_URL environment variable, and then if it is not set, it will call the function.

Patching figenv for tests

Because figenv depends heavily on the environment, even if you patch the Config object directly, environment variables in the test suite can overwrite those patches. Instead you should patch the environment.

Note

It is not possible to directly edit the attributes, they are treated as defaults.

>>> import figenv, os, unittest.mock
>>> class Config(metaclass=figenv.MetaConfig):
...   FOO = 'bar'
...
>>> assert Config.FOO == 'bar'
>>> with unittest.mock.patch.dict(os.environ, {'FOO': 'baz'}):
...     assert Config.FOO == 'baz'
...
>>>

When the patch.dict context manager is exited, the os.environ dictionary is reverted back to what it was previously.

Extra stuff figenv can do

Creating strict functions

If there is a function that should not be overwritable by the environment variables, there is decorator figenv.strict() which can be added to the function, and this will make sure it is called, and not overwritten by environment variables.

import os
import platform
from unittest.mock import patch

import figenv

class Config(metaclass=figenv.MetaConfig):

    @figenv.strict
    def HOSTNAME(cls):
        return platform.node()

with patch.dict(os.environ, {'HOSTNAME': 'myfakehost'}):
    assert Config.HOSTNAME == platform.node()

Coercion

This feature allows figenv to convert strings from environment variables to appropriate python objects automatically. Here are a list of the coercion that figenv handles by default:

  • Any capitalization of strings true or false in the environment variable will be turned into the the True or False boolean value.

  • Anything with all digits in it and only one period will be turned into a float

  • Anything that is all digits will be turned into an int

  • You can also use type annotations
    • typing.Dict or dict gets converted into a dictionary object using json.loads

    • int, bool, and float type annotations just get straight converted.

    • any type annotation with the staticmethod _coerce is used to convert the object using that method

import typing

import figenv


class csv:
    @staticmethod
    def _coerce(value):
        return value.split(',')

class Config(metaclass=figenv.MetaConfig)
    DEBUG = 'false'
    VERSION = '1.2.3'
    FEATURE_FLAG_PERCENT = '0.24'
    ALLOWLIST_USERID: csv = '123abc,456efg'
    DATA: typing.Dict = '{"name":"george"}'

assert Config.DEBUG == False
assert Config.VERSION == '1.2.3'
assert Config.FEATURE_FLAG_PERCENT == 0.24
assert Config.ALLOWLIST_USERID == ['123abc', '456efg']
assert Config.DATA == {'name': 'george'}

Data Access

The main intended way to access environment variables from figenv is to access the config objects attributes.

Example:

import figenv

class Config(metaclass=figenv.MetaConfig):
     TIMEOUT = 5


assert Config.TIMEOUT == 5
assert Config['TIMEOUT'] == 5
for key, value in Config:
    if key == 'TIMEOUT':
        assert value == 5
assert dict(Config) == {'TIMEOUT': 5}

But it can also see, you can use it by using the getitem method as if it was a dictionary as well. You can also access the config key values in a for loop and also convert the whole object to a dictionary if you want.

Load all environment variables

If for some reason you do not want to put all your configuration variables into the config object, but still want to access other environment variables, set the ENV_LOAD_ALL attribute on the config class, and it will pull values from the environment even if they do not have a default set on the class.

>>> import figenv
>>> class Config(metaclass=figenv.MetaConfig):
...   ENV_LOAD_ALL = True
...
>>> Config.USER
'wallacda'

Prefixes

If you have want to add prefixes to all of the environment variables in your config object, you can specify the ENV_PREFIX attribute.

>>> import figenv, os
>>> class Config(metaclass=figenv.MetaConfig):
...   ENV_PREFIX = 'FIGENV_'
...   USER = 'daniel'
...
>>> os.getenv('USER')
'wallacda'
>>> Config.USER
'daniel'
>>> os.environ['FIGENV_USER'] = 'newuser'
>>> Config.USER
'newuser'