Configuration
Param
A Param is a useful wrapper for storing additional metadata like
type hints
textual descriptions
allowed value range
possible value variants
optional tags for efficient
Paramsearch
For instance, the following code defines an integer Param with a specific allowed value range
param = Param(name='x', value=5, type_hint=int, allowed_range=lambda p: p in [1, 5, 10])
The name attribute is the Param unique identifier.
The allowed_range attribute introduces a condition such that x can only be set to 1,, 5 or 10.
Once set, a Param can be modified directly
param = Param(name='x', value=5, type_hint=int, allowed_range=lambda p: p in [1, 5, 10])
print(param.value) # >>> 5
param.value = 10
print(param.value) # >>> 10
The same applies for all other attributes of Param.
Configuration
A Configuration stores all the parameters of a Component.
Each parameter is wrapped into a Param.
For example, we can define a Configuration inline with a Param as follows
config = Configuration()
config.add(name='x', value=50, type_hint=int, description='An example parameter')
print(config.x) # >>> 50
config.x = 10
print(config.x) # >>> 10
We can always access the Param instance via config.get() as follows
config = Configuration()
config.add(name='x', value=50, type_hint=int, description='An example parameter')
config.get('x').value = 20
print(config.x) # >>> 20
config.get('x').variants = [10, 5, 70]
Adding a Param with via name that already exist will throw an error
config = Configuration()
config.add(name='x', value=10)
config.add(name='x', value=True) # >>> raises cinnamon.AlreadyExistingParameterException
Likewise, setting a non-existing Param will throw an error
config = Configuration()
config.add(name='x', value=10)
config.y = True # >>> raises AttributeError
Adding conditions
Configuration also support special kind of Param known as conditions.
Note
Therefore, all Param rules apply to conditions as well, such as conflicting names.
Conditions allow a user to set constraints on a Configuration, such as enforcing a specific Param combination.
Some conditions are set under the hood when adding a Param
config = Configuration()
config.add(name='x', value=5, allowed_range: lambda v: v >= 0)
config.add(name='y', is_required=True)
Here, we set two different conditions.
allowed_range: specifies thatxcan only be greater or equal than 0is_required: specifies thatycannot be set toNone
We can also write our own conditions
config = Configuration()
config.add(name='x', value=10)
config.add(name='y', value=5)
config.add_condition(name='match_x_y', condition=lambda c: c.x == c.y)
Here we specify that we only allow config to have x equal to y.
Note
So far, we have only defined conditions.
We require now some APIs to validate such conditions to ensure that Configuration instances can be used.
Validating Configurations
Configuration conditions are not executed automatically.
We can directly evaluate all conditions via Configuration.validate method
config = Configuration()
config.add(name='x', value=10, variants=[5, 15])
config.add(name='y', value=5, variants[10, 15])
config.add_condition(name='match_x_y', condition=lambda c: c.x == c.y)
config.validate() # >>> raises cinnamon.ValidationFailureException
config.validate(strict=False) # >>> False
config.x = 5
config.validate() # nothing happens, all conditions pass
Search Params
Param support tags, a set of arbitrary string keywords provided by users.
Tags allow to quickly retrieve Param that share one or more keywords from a Configuration
config = Configuration()
config.add(name='x', value=10, tags={"number"})
config.add(name='y', value=True, tags={"boolean"})
config.add(name='z', value=30, tags={"number"})
config.search_param_by_tag(tags='number') # >>> [x, z]
Param search can also be generalized based on custom conditions
config = Configuration()
config.add(name='x', value=10, tags={"number"})
config.add(name='y', value=True, tags={"boolean"})
config.add(name='z', value=30, tags={"number"})
config.search_param(conditions=[
lambda param: 'number' in param.tags
])
# >>> [x, z]
Delta copy
In many cases, we may need a slightly modified Configuration instance.
We can quickly create a Configuration instance delta copy by only specifying the parameters to change
config = Configuration()
config.add(name='x', value=5)
delta_copy = config.delta_copy(x=20)
delta_copy.x # >>> 20
config.x # >>> 5
We have created a delta copy of Configuration instance with x set to 20 instead of 5.
Default template
Configuration are usually not defined inline as shown in the previous examples, but through class methods.
In particular, the default method defines the default template for Configuration.
class MyConfig(cinnamon.configuration.Configuration):
@classmethod
def default(cls):
config = super(cls).default()
config.add(name='x', value=5)
return config
if __name__ == '__main__':
config = MyConfig.default()
config.x # >>> 5
As any class, we can define custom template methods
class MyConfig(cinnamon.configuration.Configuration):
@classmethod
def default(cls):
config = super(cls).default()
config.add(name='x', value=5)
return config
@classmethod
def custom_template(cls):
config = cls.default()
config.x = 20
config.add(name='y', value=True)
return config
Intuitively, since we are dealing with python classes, we can exploit inheritance to quickly define configuration extensions
class MyConfig(cinnamon.configuration.Configuration):
@classmethod
def default(cls):
config = super(cls).default()
config.add(name='x', value=5)
return config
class MyConfigExtension(MyConfig):
@classmethod
def default(cls):
config = super(cls).default()
config.add(name='y', value=True)
return config
Variants
In a project, we may have that a Component is bound to several Configuration.
In some of these cases, each of these Configuration are just slight parameter variations of a single Configuration template.
class CustomComponent(cinnamon.component.Component):
def __init__(self, x, y):
self.x = x
self.y = y
class CustomConfig(cinnamon.configuration.Configuration):
@classmethod
def default(cls):
config = super(cls).default()
config.add(name='x', value=5)
config.add(name='y', value=True)
return config
@classmethod
def variantA(cls):
config = cls.default()
config.x = 20
config.y = False
return config
@classmethod
def variantB(cls):
config = cls.default()
config.x = 42
config.y = True
return config
@classmethod
def variantC(cls):
config = cls.default()
# This is allowed since there is no condition prohibiting so
config.x = [1, 2, 3]
return config
In cinnamon, we can avoid explicitly defining all these Configuration templates and relying on the notion of configuration variant.
A configuration variant is a Configuration template that has at least one different parameter value.
We define variants by specifying the variants field when adding a parameter to the Configuration.
class CustomConfig(cinnamon.configuration.Configuration):
@classmethod
def default(
cls
):
config = super().default()
config.add(name='x',
value=5,
variants=[20, 42, [1, 2, 3]])
config.add(name='y',
value=False,
variants=[True])
return config
In the above code example, CustomConfig has x and y boolean parameters.
Both of them specify value variants, thus, defining 8 different CustomConfig variants, one for each combination of the two parameters.
We can quickly inspect these variants via Configuration.variants property.
config = MyConfig.default()
variants = config.variants[0]
# >>> variants[0] = {'x': 5, 'y': False}
# >>> variants[1] = {'x': 5, 'y': True}
# >>> variants[2] = {'x': 20, 'y': False}
# >>> variants[3] = {'x': 20, 'y': True}
# >>> variants[4] = {'x': 42, 'y': False}
# >>> variants[5] = {'x': 42, 'y': True}
# >>> variants[6] = {'x': [1, 2, 3], 'y': False}
# >>> variants[7] = {'x': [1, 2, 3], 'y': True}
By using Configuration.delta_copy we can quickly instantiate one of these variants
my_variant = config.delta_copy(**variants[0])
my_variant.x # >>> 5
my_variant.y # >>> False
Nesting (i.e., adding dependencies)
One core functionality of cinnamon is that Configuration can be nested to build more sophisticated ones (the same applies for Component).
Cinnamon does that via loose pointers called RegistrationKey, a compound unique identifier associated to a specific Configuration.
class ParentConfig(cinnamon.configuration.Configuration):
@classmethod
def default(
cls
):
config = super(cls).default()
config.add(name='param_1',
value=True,
type_hint=bool,
variants=[False, True])
config.add(name='param_2',
value=False,
type_hint=bool,
variants=[False, True])
config.add(name='child',
value=RegistrationKey(name='test', tags={'nested'}, namespace='testing'))
return config
class NestedChild(Configuration):
@classmethod
def default(
cls
):
config = super().default()
config.add(name='child',
value=RegistrationKey(name='test', tags={'plain'}, namespace='testing'),
return config
In the above example, ParentConfig has a child Configuration, named child, pointing to NestedChild.
Likewise, NestedChild has a child Configuration, named child.
Note
In cinnamon nested configurations are called dependencies.
You can access to a Configuration dependencies via Configuration.dependencies property.