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.
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:
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']