Intro

Settings cascade is designed for situations where you need to merge configuration settings from different hierarchical sources. The model is the way that CSS cascades onto elements. You can define config the same way that css rules get specified-

task.default:
    command: "echo hello"
    on_complete: "echo world"
project_name: "my project"

Then your app can use the config

class Task(SettingsSchema):
        _name_ = task
        command: str
        on_complete: str

config = SettingsManager(yaml.load("config.yml"), [Task])
task_config = config.task(class="default")
run_task(
        command=task_config.command,
        on_complete=task_config.on_complete,
        name=config.project_name,
)

Read the full documentation at https://settingscascade.readthedocs.io/en/latest/

Installation

You can install settingscascade from pypi-

pip install settingscascade

Loading Data

Defining Your Schema

Considering the html/css model, a Schema lines up with an element (such as a <p> or <div>). Once you define the Schemas that make up your config, elements in your settings will be mapped against the schema. An HTML document may look like

<body>
        <div class="outer">
                <p>Hello!</p>
        </div>
</body>

In that example the elements are body, div, and p. In SettingsCascade you define the elements that make sense for your app. Imagine a task runner app like Fabric. You may have the concept of Tasks and Environments.

from settingscascade import SettingsSchema

class Environment(SettingsSchema):
        _name_ = "env"
        python: str
        pythonpath: List[str]

class Task(SettingsSchema):
        _name_ = "task"
        command: List[str]
        wait: bool

A SettingsSchema class has one mandatory field- _name_. This is the name of the element as it will appear in setting files. It is the equivalent of div of p. Each schema defines annotations for the valid variables that make up a config for that element. When you load data, if the rule has an element defined, the system will do some validation of the type of the data you are loading. This is not a comprehensive check- for example, if you define List[str] this will only verify that the data is a list, it will not look at the values. typing.Any can be used as expected.

Specificity

When loading data, each section of rules will be associated with a selector, and then when your app tries to look up a rule, the most specific rule whose selector matches the current context will be returned. Consider

".env":
        val_a = "a"

"class.env":
        val_a = "b"

If you try to look up val_a from the context module.env it would return “a”, while from context class.env it would return “b”. A rule section must match ALL elements of the context to be used, but not all elements of the context need to be used in the selector. (The first example would work because there is no rule that matches module.env, but there IS a rule that matches *.env) The specificity rules are the same as CSS- the score is a 3-tuple- - Count of #ID values - Count of .class values - count of element values.

So- - .myclass == 0, 1, 0 - el.myclass == 0, 1, 1 - parent child#thechild == 1, 0, 2

Specificity scores are compared pairwise, the first value, then the second, then the third. The three examples above are listed from least specific to most.

Data Loader

The core class of SettingsCascade it the SettingsManager class. You create a settings manager by passing it a list of data dictionaries and a list of ElementSchema classes that you have defined. It will then build its internal cascade of rule definitions (verified according to the schemas you passed). The dictionaries themselves can be created any way you want- load from TOML, JSON, Yaml, a Python dict, whatever.

from pathlib import Path
from toml import loads
from settingscascade import SettingsManager

els = {EnvironmentSchema, TaskSchema}
data =  loads(Path("pyproject.toml").read_text())
default_data = {"mydefault": 42}
config = SettingsManager([default_data, data], els)

The algorithm for loading the data for each rule section is 1. Determine the selector term for this section. 2. Check for any key named _name_ - add it to the selector as a class. 3. check for any key named _id_ - add it to the selector as an id. 4. Append the selector to the context passed in from the parent to get the full selector for this section. 5. Iterate through the remaining key, value pairs. 6. If the key matches the _name_ of one of the element schemas or contains a . or #, load that value as a new section, passing the selector as the current context and the key as that sections selector term. 7. For each remaining key, if this section matches an element schema, verify that the key is in the annotations for the schema and the value has the correct type. 8. Load the key, value pairs into the config manager as a Rules section.

There are two ways to add classes or ids to a rule section selector. first, you can just add them directly as though it were css. The toml file below has four sections. The specifiers are read as environment, .prod, environment.prod, environment.prod task. This winds up working exactly like CSS, and is the most obvious way to use this library.

[environment]
setting_a = "outer"

[".prod"]
some_setting = "production"

["environment.prod"]
        name = "default_task"
        task_setting = "less"
["environment.prod".task]
        setting_a = "inner"

You’ll notice that in toml, to put a . or a # in the key of a section, you’ll have to use quotes. Because of that, the loader will also look for magic names _name_ and _id_ to pull them from the object. Below, the selector for section 2 would be tasks.default_task

[environment]
setting_a = "outer"

[[task]]
        _name_ = "default_task"
        task_setting = "less"
        [task.environment]
                setting_a = "inner"

[[task]]
task_setting = "more"

Note there are two special cases to consider from the previous example. The first is a list of dictionaries (like task). In this case the library will use the key of the list to build the selector for each element of the list. In this case it would be task.default_task and task respectively. The other is that the second list there has no _name_ variable, so will just get the selector from the list- if there were more then one item in that list with the same situation, they would override each other. In the event that two rule section have the SAME specificity, they get priority in reverse order of how they were loaded- the last section beats the first.

Detailed config to selector rules

Parse map into selector/ruleset ! Any key that is not an element is considered to be a rule ! There are two more special keynames - _name_ and _id_. If these are contained in a map, they update the selector of the parent key

keyname “keyname”: {…

keyname.typename “keyname.typename”: {…

keyname.typename “keyname”: {“_name_”: “typename, …

keyname#id “keyname”: {“_id_”: “id”, …

keyname.typename “keyname”: [{“_name_”: “typename”, …

keyname otherkeyname.nestedname “keyname”: {“otherkeyname”: {“_name_”: “nestedname”, …

Accessing Config values

The Data Context

Once the data is loaded, it can be accessed anywhere in your application by just accessing the attribute on your config object.

var = config.my_var

Whenever you access a value on the config object, it searches through all of the rulesets that is has for the specified key. It then uses its current_context to pick the one that is a match with the highest specificity. In the above example, there is no context, so it searches with “” as the selector string. You can use config.context(selector) as a context manager to get deeper values-

with config.context("task.default_task environment"):
        assert config.setting_a == "inner"

The context works like the nested tree of an html structure. the model

with config.context("el.myclass"):
        config.the_value
        with config.context("child"):
                config.the_value
                with config.context("par#inner"):
                        config.the_value

would have the same effect as an html structure of

<el class="myclass">
        the_value
        <child>
                the_value
                <par id=inner>
                        the_value
                </par>
        </child>
</el>

if you had a ruleset like

the_value: 0
el:
        the_value: 1
child:
        the_value: 2
par:
        the_value: 3
.myclass:
        the_value: 4
#inner:
        the_value: 5
.myclass #inner:
        the_value: 6
el child:
        the_value: 7

The output would be - 4 - 7 - 6

Using Schemas

Schemas let you enforce structure on rules- what attributes are actually valid for any particular element type. We have seen how to define them, lets see how you can use them.

class Task(ElementSchema):
        taskval: str

config = SettingsManager([data], [Task])
print(config.task().someval)

Each ElementSchema has a _name_ associated with it. When you access that name on the SettingsManager object, it will create an instance of the Schema for you. Basically, it will push the element onto the search context so that any values you lookup will come from that element. You can pass extra context as well using the name and identifier arguments-

with config.context("parent"):
        config.task(identifier="mytask").someval

In this case, someval would be looked up with the context “parent task#mytask”. Schema objects aren’t context managers, they keep a closure of the context when they were created, so this

with config.context("parent"):
        task = config.task(identifier="mytask")
val  = task.someval

would work the same way as the previous example. Schema objects also provides a convenience method load() which will return a dictionary of all of the resolved properties that are defined on the schema. Its the equivalent of

data = {key: getattr(task, key) for key in Task.__props__}

Jinja templating

Any string value returned from your config will be run through a Jinja2 template resolver before being returned. Any missing variables in the templates will be looked up in the config using the current context.

config = SettingsManager({
        "basic": "Var {{someval}}",
        "someval": "default",
        "task": {"someval": "override"}
}, {"task"})

config.basic == "Var default"
with config.context("task"):
        config.basic == "Var override"

This could allow you to be more flexible when merging data from multiple sources such as default, org, and user level config files. You can even add custom filters to the environment such as

config = SettingsManager({
        "myval": "{{ (1, 3) | add_two_numbers }}"
})
config.add_filter("add_two_numbers", lambda tup: tup[0] + tup[1])
config.myval == "4"

API

class settingscascade.SettingsManager(data: List[dict], els: Optional[List[Type[settingscascade.schema.ElementSchema]]] = None)

A Settingsmanager object.

Parameters
  • data – a list of settings dictionaries

  • els – a List of ElemeentSchema objects, If not specified, no elements will be created

context(new_context: str = '')

Add context onto the current context. This takes a string and appends it to the existing context. For example (using html elements-)

with config.context("body h1.intro"):
    with config.context("div.myel"):
        config.current_context == "body h1.intro div.myel"
property current_context

Gets a string that represents the current context used for settings lookups :return: str

load_data(data: dict, next_item: str = '', selector: str = '')

Loads a settings dictionary into the rule stack.

Parameters
  • data – A settings dictionary. Keys should either be selectors or value names.

  • next_item – The key for this rule-set. Pulled from the parent dict when loading recursively.

  • selector – The full context selector for any parent rule-sets that should be added to the selector for this one

class settingscascade.ElementSchema(configManager)

Class that defines the schema for a particular element in your settings heirarchy. Subclass this and add annotations to define the allowed values for this element type-

class Element(ElementSchema):
    color: str
    height: int
property context

The context stack that will be used to look up settings for this object

load()

Loads the settings for this schema into a python dictionary. Looks up the value for each property using the current context stack for this object.

Note

This will throw an error if there are settings defined on the schema that can’t be found in any level!