Bug 1255450 - [mach] Implement 'wildcard' settings for enabling sections with user-defined options, r=gps
authorAndrew Halberstadt <ahalberstadt@mozilla.com>
Mon, 28 Mar 2016 10:52:16 -0400
changeset 330687 99146ae25db725f0a1b37b624ec47fea07366e03
parent 330686 5f410a4601c184a77d6c9ac7a4fa5660aff7e359
child 330688 b552927e1a1fe9f916811236eb30352be72eaaaf
push id6048
push userkmoir@mozilla.com
push dateMon, 06 Jun 2016 19:02:08 +0000
treeherdermozilla-beta@46d72a56c57d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgps
bugs1255450
milestone48.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1255450 - [mach] Implement 'wildcard' settings for enabling sections with user-defined options, r=gps Some sections should support user-defined options. For example, in an [alias] section, the option names are not well-defined, rather specified by the user. This patch allows user-defined option names for any section that has a 'section.*' option defined. Even with 'section.*', option types are still well-defined. MozReview-Commit-ID: L34W9v9Fy28
python/mach/docs/settings.rst
python/mach/mach/config.py
python/mach/mach/test/test_config.py
--- a/python/mach/docs/settings.rst
+++ b/python/mach/docs/settings.rst
@@ -49,16 +49,36 @@ 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.
 
+Wildcards
+---------
+
+Sometimes a section should allow arbitrarily defined options from the user, such
+as the ``alias`` section mentioned above. To define a section like this, use ``*``
+as the option name. For example:
+
+.. parsed-literal::
+
+    ('foo.*', 'string')
+
+This allows configuration files like this:
+
+.. parsed-literal::
+
+    [foo]
+    arbitrary1 = some string
+    arbitrary2 = some other string
+
+
 
 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:
 
--- a/python/mach/mach/config.py
+++ b/python/mach/mach/config.py
@@ -208,28 +208,35 @@ class ConfigSettings(collections.Mapping
 
     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):
-            if option not in self._settings:
-                raise KeyError('Option not registered with provider: %s' % option)
-
-            meta = self._settings[option]
+            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):
@@ -237,34 +244,33 @@ class ConfigSettings(collections.Mapping
 
         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._settings[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)
 
-            meta = self._settings[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)
 
@@ -411,17 +417,17 @@ class ConfigSettings(collections.Mapping
                 fh.write('msgid "%s.%s.full"\n' % (section, option))
                 fh.write('msgstr ""\n\n')
 
             fh.write('# End of section %s\n\n' % section)
 
     def option_help(self, section, option):
         """Obtain the translated help messages for an option."""
 
-        meta = self[section]._settings[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)
--- a/python/mach/mach/test/test_config.py
+++ b/python/mach/mach/test/test_config.py
@@ -83,16 +83,24 @@ class Provider3(object):
 @SettingsProvider
 class Provider4(object):
     config_settings = [
         ('foo.abc', StringType, 'a', {'choices': set('abc')}),
         ('foo.xyz', StringType, 'w', {'choices': set('xyz')}),
     ]
 
 
+@SettingsProvider
+class Provider5(object):
+    config_settings = [
+        ('foo.*', 'string'),
+        ('foo.bar', 'string'),
+    ]
+
+
 class TestConfigSettings(unittest.TestCase):
     def test_empty(self):
         s = ConfigSettings()
 
         self.assertEqual(len(s), 0)
         self.assertNotIn('foo', s)
 
     def test_duplicate_option(self):
@@ -206,16 +214,34 @@ class TestConfigSettings(unittest.TestCa
             foo.xyz
 
         with self.assertRaises(ValueError):
             foo.abc = 'e'
 
         foo.abc = 'b'
         foo.xyz = 'y'
 
+    def test_wildcard_options(self):
+        s = ConfigSettings()
+        s.register_provider(Provider5)
+
+        foo = s.foo
+
+        self.assertIn('*', foo._settings)
+        self.assertNotIn('*', foo)
+
+        foo.baz = 'value1'
+        foo.bar = 'value2'
+
+        self.assertIn('baz', foo)
+        self.assertEqual(foo.baz, 'value1')
+
+        self.assertIn('bar', foo)
+        self.assertEqual(foo.bar, 'value2')
+
     def test_file_reading_single(self):
         temp = NamedTemporaryFile(mode='wt')
         temp.write(CONFIG1)
         temp.flush()
 
         s = ConfigSettings()
         s.register_provider(Provider1)