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 Param search

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 that x can only be greater or equal than 0

  • is_required: specifies that y cannot be set to None

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.