summaryrefslogtreecommitdiffstats
path: root/python/mach/mach/config.py
diff options
context:
space:
mode:
Diffstat (limited to 'python/mach/mach/config.py')
-rw-r--r--python/mach/mach/config.py461
1 files changed, 461 insertions, 0 deletions
diff --git a/python/mach/mach/config.py b/python/mach/mach/config.py
new file mode 100644
index 000000000..26c9a4482
--- /dev/null
+++ b/python/mach/mach/config.py
@@ -0,0 +1,461 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+r"""
+This file defines classes for representing config data/settings.
+
+Config data is modeled as key-value pairs. Keys are grouped together into named
+sections. Individual config settings (options) have metadata associated with
+them. This metadata includes type, default value, valid values, etc.
+
+The main interface to config data is the ConfigSettings class. 1 or more
+ConfigProvider classes are associated with ConfigSettings and define what
+settings are available.
+
+Descriptions of individual config options can be translated to multiple
+languages using gettext. Each option has associated with it a domain and locale
+directory. By default, the domain is the section the option is in and the
+locale directory is the "locale" directory beneath the directory containing the
+module that defines it.
+
+People implementing ConfigProvider instances are expected to define a complete
+gettext .po and .mo file for the en_US locale. The |mach settings locale-gen|
+command can be used to populate these files.
+"""
+
+from __future__ import absolute_import, unicode_literals
+
+import collections
+import gettext
+import os
+import sys
+from functools import wraps
+
+if sys.version_info[0] == 3:
+ from configparser import RawConfigParser, NoSectionError
+ str_type = str
+else:
+ from ConfigParser import RawConfigParser, NoSectionError
+ str_type = basestring
+
+
+TRANSLATION_NOT_FOUND = """
+No translation files detected for {section}, there must at least be a
+translation for the 'en_US' locale. To generate these files, run:
+
+ mach settings locale-gen {section}
+""".lstrip()
+
+
+class ConfigException(Exception):
+ pass
+
+
+class ConfigType(object):
+ """Abstract base class for config values."""
+
+ @staticmethod
+ def validate(value):
+ """Validates a Python value conforms to this type.
+
+ Raises a TypeError or ValueError if it doesn't conform. Does not do
+ anything if the value is valid.
+ """
+
+ @staticmethod
+ def from_config(config, section, option):
+ """Obtain the value of this type from a RawConfigParser.
+
+ Receives a RawConfigParser instance, a str section name, and the str
+ option in that section to retrieve.
+
+ The implementation may assume the option exists in the RawConfigParser
+ instance.
+
+ Implementations are not expected to validate the value. But, they
+ should return the appropriate Python type.
+ """
+
+ @staticmethod
+ def to_config(value):
+ return value
+
+
+class StringType(ConfigType):
+ @staticmethod
+ def validate(value):
+ if not isinstance(value, str_type):
+ raise TypeError()
+
+ @staticmethod
+ def from_config(config, section, option):
+ return config.get(section, option)
+
+
+class BooleanType(ConfigType):
+ @staticmethod
+ def validate(value):
+ if not isinstance(value, bool):
+ raise TypeError()
+
+ @staticmethod
+ def from_config(config, section, option):
+ return config.getboolean(section, option)
+
+ @staticmethod
+ def to_config(value):
+ return 'true' if value else 'false'
+
+
+class IntegerType(ConfigType):
+ @staticmethod
+ def validate(value):
+ if not isinstance(value, int):
+ raise TypeError()
+
+ @staticmethod
+ def from_config(config, section, option):
+ return config.getint(section, option)
+
+
+class PositiveIntegerType(IntegerType):
+ @staticmethod
+ def validate(value):
+ if not isinstance(value, int):
+ raise TypeError()
+
+ if value < 0:
+ raise ValueError()
+
+
+class PathType(StringType):
+ @staticmethod
+ def validate(value):
+ if not isinstance(value, str_type):
+ raise TypeError()
+
+ @staticmethod
+ def from_config(config, section, option):
+ return config.get(section, option)
+
+
+TYPE_CLASSES = {
+ 'string': StringType,
+ 'boolean': BooleanType,
+ 'int': IntegerType,
+ 'pos_int': PositiveIntegerType,
+ 'path': PathType,
+}
+
+
+class DefaultValue(object):
+ pass
+
+
+def reraise_attribute_error(func):
+ """Used to make sure __getattr__ wrappers around __getitem__
+ raise AttributeError instead of KeyError.
+ """
+ @wraps(func)
+ def _(*args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except KeyError:
+ exc_class, exc, tb = sys.exc_info()
+ raise AttributeError().__class__, exc, tb
+ return _
+
+
+class ConfigSettings(collections.Mapping):
+ """Interface for configuration settings.
+
+ This is the main interface to the configuration.
+
+ A configuration is a collection of sections. Each section contains
+ key-value pairs.
+
+ When an instance is created, the caller first registers ConfigProvider
+ instances with it. This tells the ConfigSettings what individual settings
+ are available and defines extra metadata associated with those settings.
+ This is used for validation, etc.
+
+ Once ConfigProvider instances are registered, a config is populated. It can
+ be loaded from files or populated by hand.
+
+ ConfigSettings instances are accessed like dictionaries or by using
+ attributes. e.g. the section "foo" is accessed through either
+ settings.foo or settings['foo'].
+
+ Sections are modeled by the ConfigSection class which is defined inside
+ this one. They look just like dicts or classes with attributes. To access
+ the "bar" option in the "foo" section:
+
+ value = settings.foo.bar
+ value = settings['foo']['bar']
+ value = settings.foo['bar']
+
+ Assignment is similar:
+
+ settings.foo.bar = value
+ settings['foo']['bar'] = value
+ settings['foo'].bar = value
+
+ You can even delete user-assigned values:
+
+ del settings.foo.bar
+ del settings['foo']['bar']
+
+ If there is a default, it will be returned.
+
+ When settings are mutated, they are validated against the registered
+ providers. Setting unknown settings or setting values to illegal values
+ will result in exceptions being raised.
+ """
+
+ class ConfigSection(collections.MutableMapping, object):
+ """Represents an individual config section."""
+ def __init__(self, config, name, settings):
+ object.__setattr__(self, '_config', config)
+ object.__setattr__(self, '_name', name)
+ object.__setattr__(self, '_settings', settings)
+
+ wildcard = any(s == '*' for s in self._settings)
+ object.__setattr__(self, '_wildcard', wildcard)
+
+ @property
+ def options(self):
+ try:
+ return self._config.options(self._name)
+ except NoSectionError:
+ return []
+
+ def get_meta(self, option):
+ if option in self._settings:
+ return self._settings[option]
+ if self._wildcard:
+ return self._settings['*']
+ raise KeyError('Option not registered with provider: %s' % option)
+
+ def _validate(self, option, value):
+ meta = self.get_meta(option)
+ meta['type_cls'].validate(value)
+
+ if 'choices' in meta and value not in meta['choices']:
+ raise ValueError("Value '%s' must be one of: %s" % (
+ value, ', '.join(sorted(meta['choices']))))
+
+ # MutableMapping interface
+ def __len__(self):
+ return len(self.options)
+
+ def __iter__(self):
+ return iter(self.options)
+
+ def __contains__(self, k):
+ return self._config.has_option(self._name, k)
+
+ def __getitem__(self, k):
+ meta = self.get_meta(k)
+
+ if self._config.has_option(self._name, k):
+ v = meta['type_cls'].from_config(self._config, self._name, k)
+ else:
+ v = meta.get('default', DefaultValue)
+
+ if v == DefaultValue:
+ raise KeyError('No default value registered: %s' % k)
+
+ self._validate(k, v)
+ return v
+
+ def __setitem__(self, k, v):
+ self._validate(k, v)
+ meta = self.get_meta(k)
+
+ if not self._config.has_section(self._name):
+ self._config.add_section(self._name)
+
+ self._config.set(self._name, k, meta['type_cls'].to_config(v))
+
+ def __delitem__(self, k):
+ self._config.remove_option(self._name, k)
+
+ # Prune empty sections.
+ if not len(self._config.options(self._name)):
+ self._config.remove_section(self._name)
+
+ @reraise_attribute_error
+ def __getattr__(self, k):
+ return self.__getitem__(k)
+
+ @reraise_attribute_error
+ def __setattr__(self, k, v):
+ self.__setitem__(k, v)
+
+ @reraise_attribute_error
+ def __delattr__(self, k):
+ self.__delitem__(k)
+
+
+ def __init__(self):
+ self._config = RawConfigParser()
+
+ self._settings = {}
+ self._sections = {}
+ self._finalized = False
+ self.loaded_files = set()
+
+ def load_file(self, filename):
+ self.load_files([filename])
+
+ def load_files(self, filenames):
+ """Load a config from files specified by their paths.
+
+ Files are loaded in the order given. Subsequent files will overwrite
+ values from previous files. If a file does not exist, it will be
+ ignored.
+ """
+ filtered = [f for f in filenames if os.path.exists(f)]
+
+ fps = [open(f, 'rt') for f in filtered]
+ self.load_fps(fps)
+ self.loaded_files.update(set(filtered))
+ for fp in fps:
+ fp.close()
+
+ def load_fps(self, fps):
+ """Load config data by reading file objects."""
+
+ for fp in fps:
+ self._config.readfp(fp)
+
+ def write(self, fh):
+ """Write the config to a file object."""
+ self._config.write(fh)
+
+ @classmethod
+ def _format_metadata(cls, provider, section, option, type_cls,
+ default=DefaultValue, extra=None):
+ """Formats and returns the metadata for a setting.
+
+ Each setting must have:
+
+ section -- str section to which the setting belongs. This is how
+ settings are grouped.
+
+ option -- str id for the setting. This must be unique within the
+ section it appears.
+
+ type -- a ConfigType-derived type defining the type of the setting.
+
+ Each setting has the following optional parameters:
+
+ default -- The default value for the setting. If None (the default)
+ there is no default.
+
+ extra -- A dict of additional key/value pairs to add to the
+ setting metadata.
+ """
+ if isinstance(type_cls, basestring):
+ type_cls = TYPE_CLASSES[type_cls]
+
+ meta = {
+ 'short': '%s.short' % option,
+ 'full': '%s.full' % option,
+ 'type_cls': type_cls,
+ 'domain': section,
+ 'localedir': provider.config_settings_locale_directory,
+ }
+
+ if default != DefaultValue:
+ meta['default'] = default
+
+ if extra:
+ meta.update(extra)
+
+ return meta
+
+ def register_provider(self, provider):
+ """Register a SettingsProvider with this settings interface."""
+
+ if self._finalized:
+ raise ConfigException('Providers cannot be registered after finalized.')
+
+ settings = provider.config_settings
+ if callable(settings):
+ settings = settings()
+
+ config_settings = collections.defaultdict(dict)
+ for setting in settings:
+ section, option = setting[0].split('.')
+
+ if option in config_settings[section]:
+ raise ConfigException('Setting has already been registered: %s.%s' % (
+ section, option))
+
+ meta = self._format_metadata(provider, section, option, *setting[1:])
+ config_settings[section][option] = meta
+
+ for section_name, settings in config_settings.items():
+ section = self._settings.get(section_name, {})
+
+ for k, v in settings.items():
+ if k in section:
+ raise ConfigException('Setting already registered: %s.%s' %
+ section_name, k)
+
+ section[k] = v
+
+ self._settings[section_name] = section
+
+ def option_help(self, section, option):
+ """Obtain the translated help messages for an option."""
+
+ meta = self[section].get_meta(option)
+
+ # Providers should always have an en_US translation. If they don't,
+ # they are coded wrong and this will raise.
+ default = gettext.translation(meta['domain'], meta['localedir'],
+ ['en_US'])
+
+ t = gettext.translation(meta['domain'], meta['localedir'],
+ fallback=True)
+ t.add_fallback(default)
+
+ short = t.ugettext('%s.%s.short' % (section, option))
+ full = t.ugettext('%s.%s.full' % (section, option))
+
+ return (short, full)
+
+ def _finalize(self):
+ if self._finalized:
+ return
+
+ for section, settings in self._settings.items():
+ s = ConfigSettings.ConfigSection(self._config, section, settings)
+ self._sections[section] = s
+
+ self._finalized = True
+
+ # Mapping interface.
+ def __len__(self):
+ return len(self._settings)
+
+ def __iter__(self):
+ self._finalize()
+
+ return iter(self._sections.keys())
+
+ def __contains__(self, k):
+ return k in self._settings
+
+ def __getitem__(self, k):
+ self._finalize()
+
+ return self._sections[k]
+
+ # Allow attribute access because it looks nice.
+ @reraise_attribute_error
+ def __getattr__(self, k):
+ return self.__getitem__(k)