Advanced topics

In this chapter we explore Concrete Settings in depth.

Setting definition

Concrete Settings’ loves reader-friendly implicit settings definitions such as:

from concrete_settings import Settings

class AppSettings(Settings):

    #: Turns debug mode on/off
    DEBUG: bool = True

In this section we discuss how such an implicit definition is parsed and processed into Setting descriptor instances.

In a nutshell Concrete Settings has to get a setting field’s name, initial value and type hint, its validators, and documentation.

@startuml
(Initial value) --> (Setting)
(Type hint) --> (Setting)
(Validators) --> (Setting)
(Documentation) --> (Setting)

note left of (Setting) : NAME
@enduml

Name

Every attribute with name written in upper case is considered a potential Setting. The exceptions are attributes starting with underscore:

from concrete_settings import Settings

class AppSettings(Settings):
    debug = True   # not a setting
    _DEBUG = True  # not a setting
    DEBUG = True   ### considered a setting

Class methods are also automatically converted to property-settings even if their names are written in upper case.

from concrete_settings import Settings, setting

class AppSettings(Settings):
    def ADMIN(self) -> str:  # automatically converted to setting
        """Admin name"""
        return 'Alex'

A method can be decorated by @setting in order to control Setting initialization. For example, to set validators:

from concrete_settings import Settings, setting
from concrete_settings import ValidationError

# a validator
def not_too_fast(speed):
    if speed > 100:
        raise ValidationError('You are going too fast!')

class CarSettings(Settings):
    @setting(validators=(not_too_fast, ))
    def MAX_SPEED(self):
        return 200

Initial value

The initial value is the value assigned to the attribute:

class AppSettings(Settings):
    DEBUG = True    # initial value is `True`
    MAX_SPEED = 10  # initial value is `10`

You can use the special Undefined value in cases when initial value is not available:

from concrete_settings import Undefined

class DBSettings(Settings):
    USERNAME: str = Undefined
    PASSWORD: str = Undefined

Undefined implies that the setting value would be set later in runtime before validation. RequiredValidator would fail validation if the setting’s value is Undefined.

It does not make much a sense to have a initial value for a property-setting since the value is computed every time a setting is read. To prevent misuse, passing a value argument raises an AssertionError when assert statements have effect.

from concrete_settings import Settings, setting

class AppSettings(Settings):
    LOG_LEVEL = 'INFO'

    def DEBUG(self) -> bool:
        return self.LOG_LEVEL == 'DEBUG'

app_settings = AppSettings()
print(app_settings.DEBUG)

Output:

False

Type hint

A type hint is defined by a standard Python type annotation:

class AppSettings(Settings):
    MAX_SPEED: int = 10  # type hint is `int`

If an attribute is not type-annotated, a type hint is computed by calling type() on the initial value. The recognized types are defined in GuessSettingType.KNOWN_TYPES. If the type is not recognized, the type hint is set to typing.Any.

class AppSettings(Settings):
    DEBUG = True     # initial value `True`, type `bool`
    MAX_SPEED = 300  # initial value `300`, type `int`

It is recommended to explicitly annotate a setting with the intended type, in order to avoid invalid type detections:

class AppSettings(Settings):
    DEBUG: bool = True       # initial value `True`, type `bool`
    MAX_SPEED: float  = 300  # initial value `300`, type `float`

Property-settings’ type hint is read from the return type annotation. If no annotation is provided, the type hint is set to typing.Any:

class AppSettings(Settings):
    def DEBUG(self) -> bool:
        return True

    def MAX_SPEED(self):
        return 300

print(AppSettings.DEBUG.type_hint)
print(AppSettings.MAX_SPEED.type_hint)

Output:

<class 'bool'>
typing.Any

The type_hint attribute is intended for validators. For example, the built-in ValueTypeValidator fails validation if the type of the setting value does not correspond to the defined type hint.

Validators

Validators is a collection of callables which validate the value of the setting. The interface of the callable is defined in the Validator protocol. If validation fails, a validator raises ValidationError with failure details. Other exception raised by validators are also wrapped (via raise from) into ValidationError. Individual Setting validators are supplied in validators argument of an explicit Setting definition. Also behaviors like validate and others can be used to add validators to a setting.

The mandatory validators are applied to every Setting in Settings class. They are defined in Settings.mandatory_validators tuple. ValueTypeValidator is the only validator in the base Settings.mandatory_validators.

The default validators are applied to a Setting that has no validators of its own. They are defined in Settings.default_validators.

Note that both lists are inherited by standard Python class inheritance rules. For example, to extend default_validators in a derived class, use concatenation. In the following example RequiredValidator is added to default_validators to prevent any Undefined values appearing in the validated settings:

from concrete_settings import Settings, Undefined
from concrete_settings.validators import RequiredValidator

class AppSettings(Settings):
    default_validators = Settings.default_validators + (RequiredValidator(), )

    ADMIN_NAME: str = Undefined

app_settings = AppSettings()
print(app_settings.is_valid())
print(app_settings.errors)

Output:

False
{'ADMIN_NAME': ['Setting `ADMIN_NAME` is required to have a value. Current value is `Undefined`']}

Property-settings are validated in the same fashion:

from concrete_settings import Settings, setting

class AppSettings(Settings):

    @setting
    def ADMIN_NAME(self) -> str:
        return 10

app_settings = AppSettings()
print(app_settings.is_valid())
print(app_settings.errors)

Output:

False
{'ADMIN_NAME': ["Expected value of type `<class 'str'>` got value of type `<class 'int'>`"]}

Finally Concrete Settings supplies a handy validate behavior to add validators to setting in “decorator” manner:

from concrete_settings import validate, ValidationError

def is_positive(value, **kwargs):
    if value <= 0:
        raise ValidationError("must be positive integer")

class AppSettings(Settings):
    SPEED: int = 50 @validate(is_positive)

Documentation

Last but not the least - documentation. No matter how well you name a setting, its purpose, usage and background should be carefully documented. One way to keep the documentation up-to-date is to do it in the code.

Concrete Settings uses Sphinx to extract settings’ docstrings from a source code. A docstring is written above the setting definition in a #: comment block:

# test.py

from concrete_settings import Settings

class AppSettings(Settings):

    #: This is a multiline
    #: docstring explaining what
    #: ADMIN_NAME is and how to use it.
    ADMIN_NAME: str = 'Alex'

print(AppSettings.ADMIN_NAME.__doc__)

Output:

This is a multiline
docstring explaining what
ADMIN_NAME is and how to use it.

Note that extracting a docstring works only if the settings are located in a readable file with source code! Otherwise documentation has to be specified as an argument in Setting constructor:

from concrete_settings import Setting, Settings

class AppSettings(Settings):

    ADMIN_NAME: str = Setting(
        'Alex',
        doc='This is a multiline\n'
            'docstring explaining what\n'
            'ADMIN_NAME is and how to use it.'
    )

Property-settings are documented via standard Python function docstrings:

# test.py

from concrete_settings import Settings, setting

class AppSettings(Settings):

    def ADMIN_NAME(self) -> str:
        '''This documents ADMIN_NAME.'''
        return 'Alex'

print(AppSettings.ADMIN_NAME.__doc__)

Output:

This documents ADMIN_NAME.

Behaviors

Setting Behaviors allow executing some logic on different stages of a Setting life cycle.

Concrete Settings utilizes matrix multiplication @ (object.__rmatmul__()) operator to attach a behavior to a Setting. The attached behaviors are stored in Setting._behaviors. When a parent Settings class is constructed a behavior.decorate(setting) is called for each attached behavior.

Let’s define the ADMIN_NAME setting from the example above as required:

from concrete_settings import Settings, Undefined
from concrete_settings.contrib.behaviors import required

class AppSettings(Settings):
    ADMIN_NAME: str = Undefined @required

Multiple behaviors can be chained via @ operator:

from concrete_settings import Settings, Undefined
from concrete_settings.contrib.behaviors import required, deprecated

class AppSettings(Settings):
    ADMIN_NAME: str = Undefined @required @deprecated

Behaviors can also decorate property-settings:

from concrete_settings import Settings, Undefined, setting
from concrete_settings.contrib.behaviors import required

class AppSettings(Settings):
    @required
    @setting
    def ADMIN_NAME(self) -> str:
        return Undefined

Validating the example above

app_settings = AppSettings()
print(app_settings.is_valid())
print(app_settings.errors)

yields the following output:

False
{'ADMIN_NAME': ['Setting `ADMIN_NAME` is required to have a value. Current value is `Undefined`']}

Inheritance and overriding settings

One of classical configuration patterns is to use multi-tier settings definitions. For example:

@startuml
(Base settings) --> (Dev Setting)
(Base settings) --> (Production Setting)
@enduml

Imagine a situation, where a setting annotated as int in Base settings is accidentally redefined in Dev or Production settings as str:

from concrete_settings import Settings

class BaseSettings(Settings):
    MAX_CONNECTIONS: int = 100
    ...

class DevSettings(BaseSettings):
    MAX_CONNECTIONS: str = '100'

Concrete Settings detects this difference and raises an exception during early structure verification:

in classes <class 'BaseSettings'> and <class 'DevSettings'> setting MAX_CONNECTIONS has the following difference(s): types differ: <class 'int'> != <class 'str'>

To tell Concrete Settings that the re-defition is valid, a Setting has to be overriden, either explicitly by passing override=True or by using @override behavior:

from concrete_settings import Settings, Setting, override

class BaseSettings(Settings):
    MAX_CONNECTIONS: int = 100
    MIN_CONNECTIONS: int = 10
    ...

class DevSettings(BaseSettings):
    MAX_CONNECTIONS: str = '100' @override
    MIN_CONNECTIONS = Setting('100', type_hint=str, override=True)

print(DevSettings().is_valid())

Output:

True

Update strategies

In most cases, a developer wants to overwrite a setting value when updating it from a source. But there are exceptions. Think of a list setting, which contains administrators’ emails, e.g.:

from typing import List
from concrete_settings import Settings

class AppSettings(Settings):
    ADMIN_EMAILS: List[str] = [
        'admin@example.com'
    ]

What if you want to append the emails defined in sources, instead of overwriting them? ConcreteSettings provides a concept of update strategies for such cases:

{
    "ADMIN_EMAILS": ["alex@my-super-app.io"]
}
from concrete_settings.sources import strategies

...

app_settings = AppSettings()
app_settings.update('/tmp/cs-quickstart-settings.json', strategies={
    'ADMIN_EMAILS': strategies.append
})
print(app_settings.ADMIN_EMAILS)

Output:

['admin@example.com', 'alex@my-super-app.io']