Bug 1255450 - [mach] Replace ConfigProvider class with config_settings attribute, r=gps

Defining settings was a little complicated. First it required overriding a '_register_settings'
method, and then it required making N calls to a second 'register_setting' method within that.

This patch simplifies the process of defining settings by only requiring a
'config_settings' attribute. This attribute should be a list of tuples:

[
  ('<section>.<option>', '<type>', <default>, set(<choices)),
]

`default` and `choices` are optional. Alternatively, 'config_settings' can be a callable
that returns a list of tuples in the same format. This still allows for greater flexibility
for more advanced cases.

MozReview-Commit-ID: F4DTTNJdJsa

--HG--
extra : rebase_source : e3dd455ba559cd3992c9c1b3eaf021c9e0707cc1
This commit is contained in:
Andrew Halberstadt 2016-03-21 17:55:41 -04:00
Родитель de4c3d11fe
Коммит 5167efa8e4
4 изменённых файлов: 297 добавлений и 208 удалений

Просмотреть файл

@ -0,0 +1,91 @@
.. _mach_settings:
========
Settings
========
Mach can read settings in from a set of configuration files. These
configuration files are either named ``mach.ini`` or ``.machrc`` and
are specified by the bootstrap script. In mozilla-central, these files
can live in ``~/.mozbuild`` and/or ``topsrcdir``.
Settings can be specified anywhere, and used both by mach core or
individual commands.
Defining Settings
=================
Settings need to be explicitly defined, along with their type,
otherwise mach will throw when loading the configuration files.
To define settings, use the :func:`~decorators.SettingsProvider`
decorator in an existing mach command module. E.g:
.. code-block:: python
from mach.decorators import SettingsProvider
@SettingsProvider
class ArbitraryClassName(object):
config_settings = [
('foo.bar', 'string'),
('foo.baz', 'int', 0, set([0,1,2])),
]
``@SettingsProvider``'s must specify a variable called ``config_settings``
that returns a list of tuples. Alternatively, it can specify a function
called ``config_settings`` that returns a list of tuples.
Each tuple is of the form:
.. code-block:: python
('<section>.<option>', '<type>', default, extra)
``type`` is a string and can be one of:
string, boolean, int, pos_int, path
``default`` is optional, and provides a default value in case none was
specified by any of the configuration files.
``extra`` is also optional and is a dict containing additional key/value
pairs to add to the setting's metadata. The following keys may be specified
in the ``extra`` dict:
* ``choices`` - A set of allowed values for the setting.
Accessing Settings
==================
Now that the settings are defined and documented, they're accessible from
individual mach commands if the command receives a context in its constructor.
For example:
.. code-block:: python
from mach.decorators import (
Command,
CommandProvider,
SettingsProvider,
)
@SettingsProvider
class ExampleSettings(object):
config_settings = [
('a.b', 'string', 'default'),
('foo.bar', 'string'),
('foo.baz', 'int', 0, {'choices': set([0,1,2])}),
]
@CommandProvider
class Commands(object):
def __init__(self, context):
self.settings = context.settings
@Command('command', category='misc',
description='Prints a setting')
def command(self):
print(self.settings.a.b)
for option in self.settings.foo:
print(self.settings.foo[option])

Просмотреть файл

@ -31,15 +31,20 @@ import collections
import gettext
import os
import sys
from functools import wraps
if sys.version_info[0] == 3:
from configparser import RawConfigParser
from configparser import RawConfigParser, NoSectionError
str_type = str
else:
from ConfigParser import RawConfigParser
from ConfigParser import RawConfigParser, NoSectionError
str_type = basestring
class ConfigException(Exception):
pass
class ConfigType(object):
"""Abstract base class for config values."""
@ -128,122 +133,31 @@ class PathType(StringType):
return config.get(section, option)
class AbsolutePathType(PathType):
@staticmethod
def validate(value):
if not isinstance(value, str_type):
raise TypeError()
if not os.path.isabs(value):
raise ValueError()
class RelativePathType(PathType):
@staticmethod
def validate(value):
if not isinstance(value, str_type):
raise TypeError()
if os.path.isabs(value):
raise ValueError()
TYPE_CLASSES = {
'string': StringType,
'boolean': BooleanType,
'int': IntegerType,
'pos_int': PositiveIntegerType,
'path': PathType,
}
class DefaultValue(object):
pass
class ConfigProvider(object):
"""Abstract base class for an object providing config settings.
Classes implementing this interface expose configurable settings. Settings
are typically only relevant to that component itself. But, nothing says
settings can't be shared by multiple components.
def reraise_attribute_error(func):
"""Used to make sure __getattr__ wrappers around __getitem__
raise AttributeError instead of KeyError.
"""
@classmethod
def register_settings(cls):
"""Registers config settings.
This is called automatically. Child classes should likely not touch it.
See _register_settings() instead.
"""
if hasattr(cls, '_settings_registered'):
return
cls._settings_registered = True
cls.config_settings = {}
ourdir = os.path.dirname(__file__)
cls.config_settings_locale_directory = os.path.join(ourdir, 'locale')
cls._register_settings()
@classmethod
def _register_settings(cls):
"""The actual implementation of register_settings().
This is what child classes should implement. They should not touch
register_settings().
Implementations typically make 1 or more calls to _register_setting().
"""
raise NotImplemented('%s must implement _register_settings.' %
__name__)
@classmethod
def register_setting(cls, section, option, type_cls, default=DefaultValue,
choices=None, domain=None):
"""Register a config setting with this type.
This is a convenience method to populate available settings. It is
typically called in the class's _register_settings() implementation.
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.
choices -- A set of values this setting can hold. Values not in
this set are invalid.
domain -- Translation domain for this setting. By default, the
domain is the same as the section name.
"""
if not section in cls.config_settings:
cls.config_settings[section] = {}
if option in cls.config_settings[section]:
raise Exception('Setting has already been registered: %s.%s' % (
section, option))
domain = domain if domain is not None else section
meta = {
'short': '%s.short' % option,
'full': '%s.full' % option,
'type_cls': type_cls,
'domain': domain,
'localedir': cls.config_settings_locale_directory,
}
if default != DefaultValue:
meta['default'] = default
if choices is not None:
meta['choices'] = choices
cls.config_settings[section][option] = meta
@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):
@ -299,38 +213,53 @@ class ConfigSettings(collections.Mapping):
object.__setattr__(self, '_name', name)
object.__setattr__(self, '_settings', settings)
@property
def options(self):
try:
return self._config.options(self._name)
except NoSectionError:
return []
def _validate(self, option, value):
if option not in self._settings:
raise KeyError('Option not registered with provider: %s' % option)
meta = self._settings[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._settings)
return len(self.options)
def __iter__(self):
return iter(self._settings.keys())
return iter(self.options)
def __contains__(self, k):
return k in self._settings
return self._config.has_option(self._name, k)
def __getitem__(self, k):
if k not in self._settings:
raise KeyError('Option not registered with provider: %s' % k)
meta = self._settings[k]
if self._config.has_option(self._name, k):
return meta['type_cls'].from_config(self._config, self._name, k)
v = meta['type_cls'].from_config(self._config, self._name, k)
else:
v = meta.get('default', DefaultValue)
if not 'default' in meta:
if v == DefaultValue:
raise KeyError('No default value registered: %s' % k)
return meta['default']
self._validate(k, v)
return v
def __setitem__(self, k, v):
if k not in self._settings:
raise KeyError('Option not registered with provider: %s' % k)
self._validate(k, v)
meta = self._settings[k]
meta['type_cls'].validate(v)
if not self._config.has_section(self._name):
self._config.add_section(self._name)
@ -343,12 +272,15 @@ class ConfigSettings(collections.Mapping):
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)
@ -392,31 +324,75 @@ class ConfigSettings(collections.Mapping):
"""Write the config to a file object."""
self._config.write(fh)
def validate(self):
"""Ensure that the current config passes validation.
@classmethod
def _format_metadata(cls, provider, section, option, type_cls,
default=DefaultValue, extra=None):
"""Formats and returns the metadata for a setting.
This is a generator of tuples describing any validation errors. The
elements of the tuple are:
Each setting must have:
(bool) True if error is fatal. False if just a warning.
(str) Type of validation issue. Can be one of ('unknown-section',
'missing-required', 'type-error')
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 ConfigProvider with this settings interface."""
"""Register a SettingsProvider with this settings interface."""
if self._finalized:
raise Exception('Providers cannot be registered after finalized.')
raise ConfigException('Providers cannot be registered after finalized.')
provider.register_settings()
settings = provider.config_settings
if callable(settings):
settings = settings()
for section_name, settings in provider.config_settings.items():
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 Exception('Setting already registered: %s.%s' %
raise ConfigException('Setting already registered: %s.%s' %
section_name, k)
section[k] = v
@ -484,5 +460,6 @@ class ConfigSettings(collections.Mapping):
return self._sections[k]
# Allow attribute access because it looks nice.
@reraise_attribute_error
def __getattr__(self, k):
return self.__getitem__(k)

Просмотреть файл

@ -7,10 +7,10 @@ from __future__ import absolute_import, unicode_literals
import argparse
import collections
import inspect
import os
import types
from .base import MachError
from .config import ConfigProvider
from .registrar import Registrar
@ -336,14 +336,18 @@ def SettingsProvider(cls):
When this decorator is encountered, the underlying class will automatically
be registered with the Mach registrar and will (likely) be hooked up to the
mach driver.
This decorator is only allowed on mach.config.ConfigProvider classes.
"""
if not issubclass(cls, ConfigProvider):
raise MachError('@SettingsProvider encountered on class that does ' +
'not derived from mach.config.ConfigProvider.')
if not hasattr(cls, 'config_settings'):
raise MachError('@SettingsProvider must contain a config_settings attribute. It '
'may either be a list of tuples, or a callable that returns a list '
'of tuples. Each tuple must be of the form:\n'
'(<section>.<option>, <type_cls>, <default>, <choices>)\n'
'as specified by ConfigSettings._format_metadata.')
if not hasattr(cls, 'config_settings_locale_directory'):
cls_dir = os.path.dirname(inspect.getfile(cls))
cls.config_settings_locale_directory = os.path.join(cls_dir, 'locale')
Registrar.register_settings_provider(cls)
return cls

Просмотреть файл

@ -9,16 +9,15 @@ import unittest
from mozfile.mozfile import NamedTemporaryFile
from mach.config import (
AbsolutePathType,
BooleanType,
ConfigProvider,
ConfigException,
ConfigSettings,
IntegerType,
PathType,
PositiveIntegerType,
RelativePathType,
StringType,
)
from mach.decorators import SettingsProvider
from mozunit import main
@ -41,48 +40,53 @@ CONFIG2 = r"""
bar = value2
"""
class Provider1(ConfigProvider):
@SettingsProvider
class Provider1(object):
config_settings = [
('foo.bar', StringType),
('foo.baz', PathType),
]
@SettingsProvider
class ProviderDuplicate(object):
config_settings = [
('dupesect.foo', StringType),
('dupesect.foo', StringType),
]
@SettingsProvider
class Provider2(object):
config_settings = [
('a.string', StringType),
('a.boolean', BooleanType),
('a.pos_int', PositiveIntegerType),
('a.int', IntegerType),
('a.path', PathType),
]
@SettingsProvider
class Provider3(object):
@classmethod
def _register_settings(cls):
cls.register_setting('foo', 'bar', StringType)
cls.register_setting('foo', 'baz', AbsolutePathType)
Provider1.register_settings()
class ProviderDuplicate(ConfigProvider):
@classmethod
def _register_settings(cls):
cls.register_setting('dupesect', 'foo', StringType)
cls.register_setting('dupesect', 'foo', StringType)
class TestConfigProvider(unittest.TestCase):
def test_construct(self):
s = Provider1.config_settings
self.assertEqual(len(s), 1)
self.assertIn('foo', s)
self.assertEqual(len(s['foo']), 2)
self.assertIn('bar', s['foo'])
self.assertIn('baz', s['foo'])
def test_duplicate_option(self):
with self.assertRaises(Exception):
ProviderDuplicate.register_settings()
def config_settings(cls):
return [
('a.string', 'string'),
('a.boolean', 'boolean'),
('a.pos_int', 'pos_int'),
('a.int', 'int'),
('a.path', 'path'),
]
class Provider2(ConfigProvider):
@classmethod
def _register_settings(cls):
cls.register_setting('a', 'string', StringType)
cls.register_setting('a', 'boolean', BooleanType)
cls.register_setting('a', 'pos_int', PositiveIntegerType)
cls.register_setting('a', 'int', IntegerType)
cls.register_setting('a', 'abs_path', AbsolutePathType)
cls.register_setting('a', 'rel_path', RelativePathType)
cls.register_setting('a', 'path', PathType)
@SettingsProvider
class Provider4(object):
config_settings = [
('foo.abc', StringType, 'a', {'choices': set('abc')}),
('foo.xyz', StringType, 'w', {'choices': set('xyz')}),
]
Provider2.register_settings()
class TestConfigSettings(unittest.TestCase):
def test_empty(self):
@ -91,6 +95,12 @@ class TestConfigSettings(unittest.TestCase):
self.assertEqual(len(s), 0)
self.assertNotIn('foo', s)
def test_duplicate_option(self):
s = ConfigSettings()
with self.assertRaises(ConfigException):
s.register_provider(ProviderDuplicate)
def test_simple(self):
s = ConfigSettings()
s.register_provider(Provider1)
@ -101,14 +111,18 @@ class TestConfigSettings(unittest.TestCase):
foo = s['foo']
foo = s.foo
self.assertEqual(len(foo), 2)
self.assertEqual(len(foo), 0)
self.assertEqual(len(foo._settings), 2)
self.assertIn('bar', foo)
self.assertIn('baz', foo)
self.assertIn('bar', foo._settings)
self.assertIn('baz', foo._settings)
self.assertNotIn('bar', foo)
foo['bar'] = 'value1'
self.assertIn('bar', foo)
self.assertEqual(foo['bar'], 'value1')
self.assertEqual(foo['bar'], 'value1')
self.assertEqual(foo.bar, 'value1')
def test_assignment_validation(self):
s = ConfigSettings()
@ -117,7 +131,7 @@ class TestConfigSettings(unittest.TestCase):
a = s.a
# Assigning an undeclared setting raises.
with self.assertRaises(KeyError):
with self.assertRaises(AttributeError):
a.undefined = True
with self.assertRaises(KeyError):
@ -155,26 +169,14 @@ class TestConfigSettings(unittest.TestCase):
with self.assertRaises(TypeError):
a.int = 'foo'
a.abs_path = '/home/gps'
with self.assertRaises(ValueError):
a.abs_path = 'home/gps'
a.rel_path = 'home/gps'
a.rel_path = './foo/bar'
a.rel_path = 'foo.c'
with self.assertRaises(ValueError):
a.rel_path = '/foo/bar'
a.path = '/home/gps'
a.path = 'foo.c'
a.path = 'foo/bar'
a.path = './foo'
def test_retrieval_type(self):
def retrieval_type_helper(self, provider):
s = ConfigSettings()
s.register_provider(Provider2)
s.register_provider(provider)
a = s.a
@ -182,18 +184,33 @@ class TestConfigSettings(unittest.TestCase):
a.boolean = True
a.pos_int = 12
a.int = -4
a.abs_path = '/home/gps'
a.rel_path = 'foo.c'
a.path = './foo/bar'
self.assertIsInstance(a.string, str_type)
self.assertIsInstance(a.boolean, bool)
self.assertIsInstance(a.pos_int, int)
self.assertIsInstance(a.int, int)
self.assertIsInstance(a.abs_path, str_type)
self.assertIsInstance(a.rel_path, str_type)
self.assertIsInstance(a.path, str_type)
def test_retrieval_type(self):
self.retrieval_type_helper(Provider2)
self.retrieval_type_helper(Provider3)
def test_choices_validation(self):
s = ConfigSettings()
s.register_provider(Provider4)
foo = s.foo
foo.abc
with self.assertRaises(ValueError):
foo.xyz
with self.assertRaises(ValueError):
foo.abc = 'e'
foo.abc = 'b'
foo.xyz = 'y'
def test_file_reading_single(self):
temp = NamedTemporaryFile(mode='wt')
temp.write(CONFIG1)