Getting started¶
A first glance¶
For now we will just assume that we have a schema that describes the expected input. Informally say that we have a very simple configuration where one can specify ones name and hobby, i.e:
name: Espen Askeladd
hobby: collect stuff
You can then instantiate a suite as follows:
# ... configuration is loaded into 'input_config' ...
import configsuite
suite = configsuite.ConfigSuite(input_config, schema)
You can now check whether data provided in input_config
is valid by accessing
suite.valid
.
if suite.valid:
print("Congratulations! The config is valid.")
else:
print("Sorry, the configuration is invalid.")
Now, given that the configuration is indeed valid you would probably like to
access the data. This can be done via the ConfigSuite
member named
snapshot
. Hence, we could change our example above to:
if suite.valid:
msg = "Congratulations {name}! The config is valid. Go {hobby}."
msg = msg.format(
name=suite.snapshot.name,
hobby=suite.snapshot.hobby,
)
print(msg)
else:
print("Sorry, the configuration is invalid.")
And if feed the example configuration above the output would be
Congratulations Espen Askeladd! The config is valid. Go collect stuff.
However, if we changed the value of name
to 13
(or even worse ["My", "name", "is kind", "of odd"]
) we would expect the configuration to be invalid and hence that the output would be Sorry, the configuration is invalid
. And as useful as this is it would be even better to gain more detailed information about the errors.
>>> print(invalid_suite.errors)
(InvalidTypeError(msg=Is x a string is false on input '13', key_path=('hobby',), layer=None),)
if suite.valid:
msg = "Congratulations {name}! The config is valid. Go {hobby}."
msg = msg.format(
name=suite.snapshot.name,
hobby=suite.snapshot.hobby,
)
print(msg)
else:
print("Sorry, the configuration is invalid.")
print(suite.errors)
Congratulations Espen Askeladd! The config is valid. Go collect stuff.
A first schema¶
The below schema is indeed the one used in our example above. It consists of a
single collection containing the two keys name
and hobby
, both of which
value should be a string.
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.NamedDict,
MK.Content: {
"name": {MK.Type: types.String},
"hobby": {MK.Type: types.String},
}
}
Notice the usage of the meta key Type
to specify the type of a specific
element and the usage of Content
to specify the content of a container.
Types¶
In Config Suite we differentiate between basic types and collections.
Basic types are single valued entities, while collections are data structures
that can hold multiple basic types. In our first example the entire
configuration was considered a collection (of type Named dict
), while name
and hobby
are basic types. And while you can define arbitrary basic types,
one cannot create new collections while using Config Suite.
Basic types¶
We will now give a brief introductory to the basic types. All of them can be
utilized in a schema by utilizing the MK.Type
keyword as displayed above.
For an introduction to how one can implement user defined basic types we
refer the reader to the advanced section.
String¶
We have already seen the usage of the String
type above. It basically accepts
everything considered a string in Python (defined by six.string_types
).
Integer¶
An Integer
is as the name suggests an integer.
Number¶
When a Number
is specified any integer or floating point value is accepted.
Bool¶
Both boolean values True
and False
are accepted.
Date¶
A date in specified in ISO-format, [YYYY]-[MM]-[DD]
that is.
DateTime¶
A date and time is expected in ISO-format ([YYYY]-[MM]-[DD]T[hh]:[mm]:[ss]
).
Collections¶
We will now explore the supported collections. These will form the backbone of your configuration. In short, if you have a dictionary where you know the keys up front, you are looking for a Named dict, if you have dictionary with arbitrary keys, you are looking for a Dict. If you have a sequence of elements, you should check out List.
Named dict¶
We have already seen the usage of a Named dict. In particular, it allows for mapping values (of potentially different types) to names that we know up front. This allows us to represent them as attributes of the snapshot (or an sub element of the snapshot). In general, if you know the values of all of the keys up front, then a named dict is the right container for you.
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.NamedDict,
MK.Content: {
"owner": {
MK.Type: types.NamedDict,
MK.Content: {
"name": {MK.Type: types.String},
"credit": {MK.Type: types.Number},
"insured": {MK.Type: types.Bool},
},
},
"car": {
MK.Type: types.NamedDict,
MK.Content: {
"brand": {MK.Type: types.String},
"first_registered": {MK.Type: types.Date}
},
},
},
}
the above example describes a configuration describing both an owner
and a
car
. for the owner
the name
, credit
and whether she is insured
is to
be specified, while for the car
the brand
and date it was
first_registered
is specified. a valid configuration could look something
like this:
owner:
name: Donald Duck
credit: -1000
insured: true
car:
brand: Belchfire Runabout
first_registered: 1938-07-01
and now, we could validate and access the data as follows:
# ... configuration is loaded into 'input_config' ...
import configsuite
suite = configsuite.ConfigSuite(input_config, schema)
if suite.valid:
print("name of owner is {}".format(
suite.snapshot.owner.name
))
print("car was first registered {}".format(
suite.snapshot.car.first_registered
))
name of owner is Donald Duck
car was first registered 1938-07-01
Notice that since keys in a named dict are made attributes in the snapshot, they all have to be valid Python variable names.
List¶
Another supported container is the List
. The data should be bundled together
either in a Python list
or a tuple
. A very concrete difference of a Config
Suite list and a Python list is that in Config Suite all elements are expected
to be of the same type. This makes for an easier format for the user as well as
the programmer when one is dealing with configurations. A very simple example
representing a list of integers would be as follows:
import configsuite
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.List,
MK.Content: {
MK.Item: {
MK.Type: types.Integer,
},
},
}
config = [1, 1, 2, 3, 5, 7, 13]
suite = configsuite.ConfigSuite(config, schema)
if suite.valid:
for idx, value in enumerate(suite.snapshot):
print("config[{}] is {}".format(idx, value))
config[0] is 1
config[1] is 1
config[2] is 2
config[3] is 3
config[4] is 5
config[5] is 7
config[6] is 13
A more complex example can be made by considering our example from the
NamedDict
section and imagining that an owner
could have multiple cars
that was to be contained in a list.
import datetime
import configsuite
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.NamedDict,
MK.Content: {
"owner": {
MK.Type: types.NamedDict,
MK.Content: {
"name": {MK.Type: types.String},
"credit": {MK.Type: types.Number},
"insured": {MK.Type: types.Bool},
},
},
"cars": {
MK.Type: types.List,
MK.Content: {
MK.Item: {
MK.Type: types.NamedDict,
MK.Content: {
"brand": {MK.Type: types.String},
"first_registered": {MK.Type: types.Date}
},
},
},
},
},
}
config = {
"owner": {
"name": "Donald Duck",
"credit": -1000,
"insured": True,
},
"cars": [
{
"brand": "Belchfire Runabout",
"first_registered": datetime.date(1938, 7, 1),
},
{
"brand": "Duckworth",
"first_registered": datetime.date(1987, 9, 18),
},
]
}
suite = configsuite.ConfigSuite(config, schema)
if suite.valid:
print("name of owner is {}".format(suite.snapshot.owner.name))
for car in suite.snapshot.cars:
print("- {}".format(car.brand))
name of owner is Donald Duck
- Belchfire Runabout
- Duckworth
Notice that suite.snapshot.cars
is returned as a tuple
-like structure. It
is iterable, indexable (suite.snapshot.cars[0]
) and immutable.
Dict¶
The last of the data structures is the Dict
. Contrary to the NamedDict
one
does not need to know the keys upfront and in addition the keys can be of other
types than just strings
. However, the restriction is that all the keys needs
to be of the same type and all the values needs to be of the same type. The
rationale for this is similar to that one of the list. Uniform types for
arbitrary sized configurations are easier and better, both for the user and the
programmer. A simple example mapping animals to frequencies are displayed below.
import configsuite
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.Dict,
MK.Content: {
MK.Key: {MK.Type: types.String},
MK.Value: {MK.Type: types.Integer},
},
}
config = {
"donkey": 16,
"horse": 28,
"monkey": 13,
}
suite = configsuite.ConfigSuite(config, schema)
assert suite.valid
for animal, frequency in suite.snapshot:
print("{} was observed {} times".format(animal, frequency))
donkey was observed 16 times
horse was observed 28 times
monkey was observed 13 times
As you can see, the elements of a Dict
is accessible in (key, value)
pairs
in the same manner dict.items()
would provide for a Python dictionary. The
reason for not supporting indexing by key is Dict
, contrary to NamedDict
,
is for dictionaries with an unknown set of keys. Hence, processing them as
key-value-pairs is the only rational thing to do.
Configuration readiness¶
A very central concept in Config Suite is that of configuration readiness.
Given that our configuration is indeed valid we can trust that
suite.snapshot
will describe all values as defined in the schema and that
all the values are valid. Hence, we do not need to check for availability nor
correctness of the configuration.
Readable¶
The concept of configuration readiness implies if one specified a value in the
schema, one is to expect that that piece of data is indeed present in the
snapshot. But what if the configuration fed to the suite is not valid? If the
errors appear in basic types, one can still access all the data as expected
(i.e. config.snapshot.owner.name
from the car example above). However, if
a container is of the wrong type one cannot guarantee such a thing. In
particular, if we bring back the single-car example from above and consider the
following configuration:
owner:
name: Donald Duck
credit: -1000
insured: true
car:
- my first car
- my second car
The car
data is completely off and there is no way one could provide a
reasonable value for config.snapshor.car.brand
. In such scenarios the
configuration is deemed unreadable. There is a special marker for this,
namely ConfigSuite.readable
. If readable
is true, then the snapshot can be
built and all the entire configuration can be accessed. However, if the suite
is not readable
and one tries to fetch the snapshot an AssertionError
will
be raised.
Note that all valid suites also are readable. And that all unreadable suites also are invalid.
Allow None¶
For certain configurations it may be reasonable to provide None
as the
value. The AllowNone
type is only valid for BasicType
’s, not for
containers. Setting AllowNone
for anything but BasicType
will result
in an invalid schema.
Let us see how the owner section of the cars
schema could be configured
in order for a configuration with None
to pass.
import configsuite
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.NamedDict,
MK.Content: {
"owner": {
MK.Type: types.NamedDict,
MK.Content: {
"name": {MK.Type: types.String},
"credit": {
MK.Type: types.Number,
MK.AllowNone: True,
MK.Required: False,
},
"insured": {MK.Type: types.Bool},
},
},
},
}
config = {
"owner": {
"name": "Scrooge",
"credit": None,
"insured": False,
},
}
suite = configsuite.ConfigSuite(config, schema)
assert suite.valid
owner = suite.snapshot.owner
print("{} has a credit of {}".format(owner.name, owner.credit))
Scrooge has a credit of None
Allow Empty¶
Occasionally one would like to require variable length containers (lists and dicts) to have at least one element. This can be implemented with a validator on the container (see Validators). However, as this feature have been requested multiple times, we’ve decided to implement explicit support for it without having to implement your own validator. Note that by default all containers are allowed to be empty.
import configsuite
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.List,
MK.AllowEmpty: False,
MK.Content: {
MK.Item: {MK.Type: types.Integer},
},
}
non_empty_suite = configsuite.ConfigSuite([0, 1, 2, 3, 4], schema)
assert non_empty_suite.valid
empty_suite = configsuite.ConfigSuite([], schema)
assert not empty_suite.valid
Default values¶
So far all entries in your configuration file have been mandatory to fill in.
And if some key in a Named dict would be missing a MissingKeyError
would be
registered. However, this is not always the wanted behaviour. By using the
MetaKeys.Required
option you can control whether a key is indeed required.
You could change the cars
schema above such that credit
would be optional
as follows:
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.NamedDict,
MK.Content: {
"owner": {
MK.Type: types.NamedDict,
MK.Content: {
"name": {MK.Type: types.String},
"credit": {
MK.Type: types.Number,
MK.Required: False,
},
"insured": {MK.Type: types.Bool},
},
},
"cars": {
MK.Type: types.List,
MK.Content: {
MK.Item: {
MK.Type: types.NamedDict,
MK.Content: {
"brand": {MK.Type: types.String},
"first_registered": {MK.Type: types.Date}
},
},
},
},
},
}
And then if no credit
was specified a MissingKeyError
would not be
registered. However, recall the principle of configuration readiness. Since,
the programmer should not have to special case whether or not the value is
present in the snapshot
. The snapshot
is always built based on the schema
and hence suite.snapshot.owner.credit
would indeed be an attribute
independently of whether the user has configured it. In this scenario the value
of suite.snapshot.owner.credit
would be None
.
ConfigSuite
requires that any entity that is not required must also allow
None
.
How to specify default values¶
There exists two ways of providing default values in Config Suite. You are
to either specify it in the schema via the keyword MetaKeys.Default
. This
has the advantage of being able to provide default values for BasicTypes
within containers. The disadvantage is that you would need to edit the code to
change the default values and hence site or project specific defaults are not
suited for this purpose. The second way of specifying default are via layers
.
Note that no element should be both required and have a given Default
value.
Schema defaults¶
The default value for any BasicType
may be set within the schema itself by
using the MK.Default
key. The Type
of MK.Default
must be consistent
with the schema configuration, otherwise it will not be valid. Please note that
any Validators or Transformations are applied to default values as well,
and they can be regarded as if they were coming directly from the user. Setting
a default value implies that it is not required, and thus MK.Required
must
be set to False
.
Let us see how the owner
section could be configured with default value for
the credit
:
import configsuite
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.NamedDict,
MK.Content: {
"owner": {
MK.Type: types.NamedDict,
MK.Content: {
"name": {MK.Type: types.String},
"credit": {
MK.Type: types.Number,
MK.Default: 0,
MK.Required: False,
MK.AllowNone: True,
},
"insured": {MK.Type: types.Bool},
},
},
},
}
config = {
"owner": {
"name": "Scrooge",
"insured": False,
},
}
suite = configsuite.ConfigSuite(config, schema)
assert suite.valid
owner = suite.snapshot.owner
print("{} has a credit of {}".format(owner.name, owner.credit))
Scrooge has a credit of 0
Layers¶
Layers is a fundamental concept in Config Suite that enables you to retrieve configurations from multiple sources in a consistent manner. It can be utilized to give priority to different sources, being application defaults, installation defaults, project or user settings, as well as case specific configuration. It can also be utilized to represent changes in configuration from a UI in a consistent manner.
In short, a layer is, a possibly incomplete, configuration source. Multiple layers can be stacked on top of each other to form a single configuration. In such a stack, top layers take precedence over lower layers. For each of the types there are specific rules for how that type is merged when multiple layers are combined into a single value.
Layers can be passed to a suite via the keyword argument layers
. In
particular, if constructed as follows
import configsuite
from configsuite import MetaKeys as MK
from configsuite import types
schema = {
MK.Type: types.NamedDict,
MK.Content: {
"a": {MK.Type: types.Integer},
"b": {MK.Type: types.Integer},
"c": {MK.Type: types.Integer},
}
}
config = { "a": 0 }
middle_layer = { "a": 1, "b": 1 }
bottom_layer = { "a": 2, "b": 2, "c": 2}
suite = configsuite.ConfigSuite(
config,
schema,
layers=(bottom_layer, middle_layer),
)
print(suite.valid)
print(suite.snapshot.a)
print(suite.snapshot.b)
print(suite.snapshot.c)
True
0
1
2
This will result in the layers (bottom_layer, middle_layer, config)
, where
elements in config
takes precedence over the two other layers and the
elements in middle_layer
over elements in bottom_layer
.
Basic types¶
Basic types are simply overwritten and only the value from the top most layer specifying that value is kept.
Named dicts and dicts¶
Named dicts and dicts are by default joined in an update kind of fashion. All
the values are joined recursively, key by key. This implies that for the
cars
-example with the following layers:
# Lower level
owner:
name: Donald Duck
credit: 100
# Upper level
owner:
name: Scrooge McDuck
insured: True
would result in the following after being merged:
# Merged configuration
owner:
name: Scrooge McDuck
credit: 100
insured: True
Lists¶
Lists are by default appended, with the top layer elements appearing after
lower levels. If we again lock at the cars
-example:
# Lower level
cars:
-
brand: Belchfire Runabout
first_registered: 1938-7-1
-
brand: Duckworth
first_registered: 1987-9-18
# Upper level
cars:
-
brand: Troll
first_registered: 1956-11-6
would result in the following after being merged:
# Merged configuration
cars:
-
brand: Belchfire Runabout
first_registered: 1938-7-1
-
brand: Duckworth
first_registered: 1987-9-18
-
brand: Troll
first_registered: 1956-11-6
Documentation generation¶
The available functionality for generating documentation is still rather limited, but will continously be improved upon.
Programatically¶
You can pass your schema to configsuite.docs.generate
and it will generate
documentation as reStructuredText.
Sphinx¶
Config Suite includes a sphinx extension for generating documentation. It has
only been tested to work with the default sphinx theme; Alabaster
. The
extension must be included in the conf.py
file:
extensions += "configsuite.extension.ext"
The sphinx directive configsuite
can then be added in your documentation as
follows:
.. configsuite::
:module: module.function
Where the function
value points to a function that returns a valid
Config Suite schema.
The generated documentation will contain a section for each container and can
be expanded by clicking the header. There is fairly limited graphical designs
added by default, but you are free to include more. The generated html files
will consist of four classes
; cs_top_container
, cs_container
,
cs_header
, cs_content
and cs_children
. The cs_top_container
contains the entire configuration, while each subsection in the schema will
have at least one of each of the remaining classes.
The cs_container
is the main container holding one of each of cs_header
and cs_content
. The cs_content
contains the text from MK.Description
and includes the messages from any Validators
and Tansformations
.
The cs_children
class furthermore contains a new node for each sub-element
that is built with the same classes above.
Given the nature of the schema’s one would typically end up with many
cs_containers
. We have tried to facilitate specific customization if the
user would like to have that, by including the possibility of unique id
’s
for every container. The id is for e.g. cs_{}_container
, where {}
will be replaced by the title.
You can then include a customized .css
file that acts on each item.
Validators¶
Validators enables validation beyond the type validation. As a first example,
let us say that you have a value in your configuration that should only contain
characters from the alphabet and spaces. This would be a quite natural
validation of the name
field in our first example.
First, you need to write a function that validates the requirements above and returns its result as a boolean.
import configsuite
@configsuite.validator_msg("Is x a valid name")
def _is_name(name):
return all(char.isalpha() or char.isspace() for char in name)
Notice the decorator validator_msg
. This adds a statement regarding the
purpose of the validator to the validator and a statement regarding the result
of the validation to the returned result. These messages are used both if a validator
fails to register errors as well as to generate documentation. In particular:
print(_is_name.msg)
print(_is_name("1234").msg)
print(_is_name("My Name").msg)
Is x a valid name
Is x a valid name is false on input '1234'
Is x a valid name is true on input 'My Name'
Afterwards, you can add this to your schema as follows:
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.NamedDict,
MK.Content: {
"name": {
MK.Type: types.String,
MK.ElementValidators: (_is_name,),
},
"hobby": {MK.Type: types.String},
}
}
Notice that we do not have to check that the input is a string. This is because type validation is always carried out first and the validator is only applied if the type validation succeeded.
Transformations¶
Transformations enables changing the data in the merged configuration before it
is validated and the snapshot becomes accessible. A simple example of this is
that you would like to support scientific notation for numbers in your
configuration files. This is a well-known short coming of PyYAML. In
particular, you would like the cars
example to support the following:
owner:
name: Donald Duck
credit: 1e10
insured: true
cars: []
However, loading the above from a yml
-file would yield the following data:
config = {
"owner": {
"name": "Donald Duck",
"credit": "1e10",
"insured": True,
},
"cars": [],
}
Note that the value of credit
is a string. However, it is easy to write a
transformer for this purpose.
import configsuite
_num_convert_msg = "Tries to convert input to a float"
@configsuite.transformation_msg(_num_convert_msg)
def _to_float(num):
return float(num)
And now, we can insert this into the schema as follows:
from configsuite import types
from configsuite import MetaKeys as MK
schema = {
MK.Type: types.NamedDict,
MK.Content: {
"owner": {
MK.Type: types.NamedDict,
MK.Content: {
"name": {MK.Type: types.String},
"credit": {
MK.Type: types.Number,
MK.Required: False,
MK.AllowNone: True,
MK.Transformation: _to_float,
},
"insured": {MK.Type: types.Bool},
},
},
"cars": {
MK.Type: types.List,
MK.Content: {
MK.Item: {
MK.Type: types.NamedDict,
MK.Content: {
"brand": {MK.Type: types.String},
"first_registered": {MK.Type: types.Date}
},
},
},
},
},
}
Last, we observe that
suite = configsuite.ConfigSuite(
config,
schema,
)
print(suite.valid)
print(suite.snapshot.owner.credit)
True
10000000000.0
As a final note about transformations it should be said that currently Config Suite does not validate readability in between transformations. This implies that if a transformations has the capability of changing the data type of a collection, then the promise of the transformations being provided with data of the correct type is only true as long as the transformations preserve this while being applied.