Using Conformity Settings

In addition to schema fields and simple validator tools to use those fields, Conformity offers a considerably more complex set of tools called Settings. This document describes Settings and how to use them.

What Are Settings?

Conformity Settings is a tool for defining complex application settings schemas that are carefully and strictly validated so that problems with these settings can be detected early, at application startup, rather than later on. Originally part of PySOA, Settings was moved to Conformity when its usage became prevalent outside of the context of services. All of the code supporting Conformity Settings can be found in conformity.settings.

In concept, you define settings for your application by extending settings.Settings and overriding its schema and defaults attributes to declare the validation rules for your settings and what defaults, if any, apply to those settings. Once defined, you construct your extended Settings class, passing it the dictionary of configured settings, which validates the settings according to the defined schema. You can then use the constructed Settings object as an immutable Mapping, accessing your settings as you would with any other Mapping.

Creating a Settings Schema

The first step in creating a settings schema is to extend the Settings class. You will not override any of its methods, but you will override its two attributes:

  • schema: This has type settings.SettingsSchema, which is an alias for typing.Mapping[six.text_type, Base]. Each key in this mapping is a required key in your settings, and the value is a Conformity field to validate. Any Conformity field is allowed, of course, so your settings can be strings, Booleans, integers, structures (lists and dictionaries), ClassConfigurationSchemas, and more. No key is optional, and extra/unknown keys are not permitted in this top-level settings dictionary (though nested dictionaries can permit this). However…

  • defaults: This has type settings.SettingsData, which is an alias for typing.Mapping[six.text_type, typing.Any]. Overriding and putting values in this field is optional, but doing so establishes defaults for any keys omitted from the validated settings. So while no top-level settings keys are optional, they can have defaults in defaults so that they aren’t required to be specified. defaults also isn’t just for top-level settings. If any of your settings fields are structures, defaults can hold nested default values for those structures as well. The defaults will be recursively merged with the settings values provided.

Furthermore, superclass schemas and defaults are inherited and merged with subclass schemas and defaults, permitting you to define a base class of common schemas and defaults and then subclasses of non-common schemas and defaults that inherit the common schemas and defaults. Consider this example:

class CommonSettings(Settings):
    schema: SettingsSchema = {
        'foo': fields.UnicodeString(),
        'bar': fields.Dictionary({
            'one': fields.UnicodeString(),
            'two': fields.List(fields.Integer()),
        }),
    }

    defaults: SettingsData = {
        'bar': {'one': 'World'},
    }

All the fields in this example – foo, bar, bar.one, and bar.two – are required. However, bar and bar.one are defaulted, so a mapping would be valid by this CommonSettings as long as it had foo and bar.two:

config1 = {'foo': 'Hello', 'bar': {'two': [1, 2, 3]}}  # valid
config2 = {'foo': 'Hello', 'bar': {'one': 'Overrides default', 'two': [1, 2, 3]}}  # valid
config3 = {}  # invalid
config4 = {'foo': 'Hello': 'bar': {}}  # invalid

Now we extend CommonSettings:

class ClientSettings(CommonSettings):
    schema: SettingsSchema = {
        'baz': fields.Integer(),
        'qux': fields.SchemalessDictionary(),
    }

    defaults: SettingsData = {
        'qux': {},
    }

class ServerSettings(CommonSettings):
    schema: SettingsSchema = {
        'baz': fields.Float(),
        'qux': fields.List(fields.UnicodeString()),
    }

    defaults: SettingsData = {
        'foo': 'Default foo',
        'bar': {'one': 'Default bar.one'},
        'baz': 1.23,
    }

ClientSettings in this example will have all of the settings and defaults from CommonSettings, as well as fields baz (an integer) and qux (a schemaless dictionary) and default qux (an empty dictionary). ServerSettings will also have all of the settings and defaults from CommonSettings, as well as fields baz (a float) and qux (a list of strings) and defaults foo, bar.one, and baz.

Notice that ServerSettings specified a default that overrides a default specified in CommonSettings. Schemas and defaults are inherited, but when a subclass specifies a field or default already specified in the parent, it overrides that parent definition.

If we were to extend ServerSettings again, the new subclass would have all the settings and defaults specified in and inherited by ServerSettings, as well as whatever new settings and defaults the subclass specified. This pattern continues and accumulates indefinitely down through the inheritance hierarchy, with the schemas and defaults from the subclass always taking precedence over conflicting settings and schemas from its parent classes.

Multiple Inheritance

First: multiple inheritance is discouraged. For many reasons, it can have unexpected and sometimes unfortunate side effects that Conformity can’t plan for or overcome. However, Conformity does its best to handle multiple inheritance. If your Settings subclass or one of its subclasses (or so on) uses multiple inheritance, Conformity behaves as follows:

  • If a base class is not a subclass of Settings, it is simply ignored. Conformity does not look for or care about any schema or defaults attributes on that class (or the absence thereof).

  • If a base class is a subclass of Settings, its schema and/or defaults (if any) will be merged with the schema and/or defaults (if any) from the other base classes and the current class to form the final schema and defaults specification. Order of precedence is handled from the rightmost base class to the leftmost base class in that order, just like Python method inheritance. So, for example:

    class ComplexSettings(SomeSettings, OtherSettings, MoreSettings, WeirdSettings):
        schema: SettingsSchema = { ... }
    
        defaults: SettingsData = { ... }
    

    In this example, Conformity will first take the effective schema and defaults from WeirdSettings. Next, it will merge those with the schema and defaults from MoreSettings, and if any conflicts exist, the items in MoreSettings will take precedence. It will then merge those with the schema and defaults from OtherSettings in the same manner, and then merge those with the schema and defaults from SomeSettings. At this point, Conformity will have the total set of inherited schema and defaults. It will then merge in the schema and defaults from ComplexSettings, with the values from ComplexSettings taking precedence if any conflicts arise with the total inherited schema and defaults, and at this point the final schema and defaults for ComplexSettings will be complete.

If your class has a mixture of base classes that are and are not subclasses of Settings, then the second rule still applies for determining precedence, and the base classes that are not subclasses of Settings are just skipped over and not considered.

As you can see, multiple inheritance is complicated and tricky. It can make it hard to understand what your effective settings are, but sometimes it might just also be the only way to achieve what you need to achieve without duplicating lots of schema. As such, we leave it to you, the developer, to determine whether to use this feature.

Using Settings Objects

Once you’ve specified your settings schema and defaults, it’s time to use them! Settings extends the Mapping interface, so all of the normal methods and operators you would expect to use on an immutable mapping can be used on an instance of Settings. The constructor has a single argument—a SettingsData object (just a mapping, so a regular dictionary is fine). When you instantiate your Settings subclass, that argument is merged with the defaults and then validated according to the schema. Any validation error raises a Settings.ImproperlyConfigured exception.

Demonstration using the example classes from the previous section:

config = {'foo': 'Hello', 'bar': {'two': [1, 2, 3]}, 'baz': 42}

settings = ClientSettings(config)  # would raise `Settings.ImproperlyConfigured` if `config` was invalid

print(settings['foo'])  # Hello
print(settings['bar']['one'])  # World
print(settings['bar']['two'])  # [1, 2, 3]
print(settings['baz'])  # 42
print(settings['qux'])  # {}

Copyright © 2019 Eventbrite, freely licensed under Apache License, Version 2.0.

Documentation generated 2019 November 02 02:50 UTC.