Source code for yaml_config.loaders

from . elements import KeyedElem, CategoryElem, ListElem, ConfigDict

from abc import ABCMeta, abstractmethod

import yc_yaml as yaml


[docs]class YamlConfigLoaderMixin: """Converts a ConfigElement class into a class that also knows how to load and dump the configuration to file. A class variable of some sort, to be overridden by the class end-user, is expected to hold the information to initialize the ConfigElement base. Only KeyedElem, CategoryElem, and ListElem really make since as bases to mix this in with.""" # Note below that the method resolution order in the classes that call # YamlConfigMixin method last. That's needed to make it easy to call the # ConfigElem classes' __init__ method easily, but also to ensure that the # abstract methods in this mixin are ignored, as they must be. __metaclass__ = ABCMeta HEADER = "" # This is expected to be overridden by the main class. type = None def dump(self, outfile, values=None, show_comments=True, show_choices=True): """Write the configuration to the given output stream. :param stream outfile: A writable stream object :param {} values: Write the configuration file with the given values inserted. Values should be a dictionary as produced by YamlConfigLoader.load(). :param bool show_comments: When dumping the config file, include help_text and general element information as comments. Default True. :param bool show_choices: When creating comments, include the choices available for each item. Default True. """ # We're recursively generating a list of pyYaml events, which will # then be emitted to create the yaml file. Each element knows how to # represent itself and how to include any child elements. events = list() events.extend([yaml.StreamStartEvent(), yaml.DocumentStartEvent()]) events.extend(self.yaml_events(values, show_comments, show_choices)) events.extend([yaml.DocumentEndEvent(), yaml.StreamEndEvent()]) yaml.emit(events, outfile) def load(self, infile, partial=False): """Load a configuration YAML file from the given stream, and then validate against the config specification. :param stream infile: The input stream from which to read. :param bool partial: The infile is not expected to be a complete configuration, so missing 'required' fields can be ignored. :returns ConfigDict: A ConfigDict of the contents of the configuration file. :raises IOError: On stream read failures. :raises YAMLError: (and child exceptions) On YAML format issues. :raises ValueError, RequiredError, KeyError: As per validate(). """ raw_data = yaml.load(infile) values = self.normalize(raw_data) return self.validate(values, partial=partial) @staticmethod def load_raw(infile): """Load the raw config. This just does a yaml.load with no validation. You're expected to validate separately.""" return yaml.load(infile) def load_empty(self, partial=True): """Get a copy of the configuration, as if we had loaded an empty file. Essentially, get a configuration with just the defaults. :param bool partial: The config is not expected to be complete. :returns ConfigDict: A ConfigDict of the contents of the configuration file. :raises ValueError, RequiredError, KeyError: As per validate(). """ return self.validate(self.type(), partial=partial) def load_merge(self, base_data, infile, partial=False): """Load the data infile, merge it into base_data, and then validate the combined result. :param base_data: Existing data to merge new data into. :param file infile: The input file object. :param bool partial: The infile is not expected to be a complete configuration, so missing 'required' fields can be ignored. :returns ConfigDict: A ConfigDict of the contents of the configuration file. :raises IOError: On stream read failures. :raises YAMLError: (and child exceptions) On YAML format issues. """ new_data = yaml.load(infile) new_data = self.normalize(new_data) data = self.merge(base_data, new_data) return self.validate(data, partial=partial) def merge(self, old, new): """This should be overridden by the element class.""" raise NotImplementedError @abstractmethod def yaml_events(self, values, show_comments, show_choices): """This is expected to be defined by the co-inherited ConfigElement type.""" return [values, show_comments, show_choices] @abstractmethod def validate(self, data, partial=False): """This is expected to be defined by the co-inherited ConfigElement type.""" _ = partial return data @abstractmethod def normalize(self, values): """This will be overridden.""" return values @abstractmethod def find(self, dotted_key): """This is expected to be defined by the co-inherited ConfigElement type.""" return dotted_key def set_default(self, dotted_key, value): """Sets the default value of the element found using dotted_key path. See ' the structure of the elements relative to this one, which is typically the base ConfigElement instance. Each component of the dotted key must correspond to a named key at that level. In cases such as lists where then sub_elem doesn't have a name, a '*' should be given. Why ever use this? Because in many cases the default is based on run-time information. Examples: :: class DessertConfig(yc.YamlConfigLoader): ELEMENTS = [ yc.KeyedElem('pie', elements=[ yc.StrElem('fruit') ] ] config = DessertConfig() # Set the 'fruit' element of the 'pie' element to have a default of 'apple'. config.set_default('pie.fruit', 'apple') class Config2(yc.YamlConfigLoader): ELEMENTS = [ yc.ListElem('cars', sub_elem=yc.KeyedElem(elements=[ yc.StrElem('color'), yc.StrElem('make') ] ] def __init__(self, default_color): # The Config init is a good place to do this. # Set all the default color for all cars in the 'cars' list to red. config.set_default('cars.*.color', 'red') super(self, Config2).__init__() :param str dotted_key: The dotted key path to the element to set the default on. :param value: The value to set the default to. This validated. :raises ValueError: If the default fails validation. :return: None """ # The ConfigElement's find method does all the heavy lifting. elem = self.find(dotted_key) elem.default = value
[docs]class YamlConfigLoader(KeyedElem, YamlConfigLoaderMixin): """Defines a YAML config specification, where the base structure is a strictly keyed dictionary. To use this, subclass it and override the ELEMENTS class variable with a list of ConfigElement instances that describe the keys for config. :: import yaml_config as yc class StrictConfig(yc.YamlConfigLoader): # These are the only keys allowed. ELEMENTS = [ yc.StrElem('first_name', required=True), yc.StrElem('last_name', required=True), yc.RegexElem('middle_initial', regex=r'[A-Za-z]'), yc.IntRangeElem('age', vmin=1, required=True) ] A valid config could look like: :: first_name: Bob last_name: Sagat age: 59 :cvar [str] HEADER: The documentation that should appear at the top of the config file. :cvar [ConfigElement] ELEMENTS: Override this with a list of element types describing your configuration. """ ELEMENTS = [] def __init__(self): """Initialize the config.""" super(YamlConfigLoader, self).__init__(elements=self.ELEMENTS, help_text=self.HEADER) # The name checking in __init__ will reject this name if set normally. self.name = '<root>'
[docs]class CatYamlConfigLoader(CategoryElem, YamlConfigLoaderMixin): """This is just like YamlConfigLoader, except instead of giving a list of elements to use as strict keys in a KeyedElem, we get a single BASE to use as the type for each sub-element in a CategoryElem. Example: :: import yaml_config as yc class UserConfig(yc.CateYamlConfigLoader): # This is the type that each key must conform to. BASE = yc.KeyedElem(elements=[ # We define elements just like for the YamlConfigLoader. As a # KeyedElem, these are the only keys allowed yc.StrElem('first_name', required=True), yc.StrElem('last_name', required=True), yc.IntRangeElem('age', vmin=1, required=True) ] In this case, a valid config can have many users defined: :: coulson: first_name: Phillip last_name: Coulson age: 52 mmay: first_name: Melinda last_name: May age: 54 :cvar [str] HEADER: The documentation that should appear at the top of the config file. :cvar ConfigElement BASE: A single ConfigElement describing what all the keys at the base level of this config must look like. """ BASE = None def __init__(self): super(CatYamlConfigLoader, self).__init__( sub_elem=self.BASE, help_text=self.HEADER) # The name checking in __init__ will reject this name if set normally. self.name = '<root>'
[docs]class ListYamlConfigLoader(ListElem, YamlConfigLoaderMixin): """A YamlConfigLoader where the base element is a ListElem. Like normal list elements, all items in the list must have the same element type, described by BASE. Example: :: import yaml_config as yc class ShoppingList(yc.ListYamlConfig): BASE = yc.StrElem() An example 'shopping list' config could look like: :: - banannas - correctly spelled bananas - apples - cantaloupe :cvar [str] HEADER: The documentation that should appear at the top of the config file. :cvar ConfigElement BASE: A single ConfigElement describing what each item in the base level list of this config must look like. """ BASE = None def __init__(self): super(ListYamlConfigLoader, self).__init__( sub_elem=self.BASE, help_text=self.HEADER) self.name = '<root>'