Rev 6123: (vila) Start supporting option expansion in config stacks. (Vincent Ladeuil) in file:///home/pqm/archives/thelove/bzr/%2Btrunk/
Canonical.com Patch Queue Manager
pqm at pqm.ubuntu.com
Fri Sep 2 08:34:55 UTC 2011
At file:///home/pqm/archives/thelove/bzr/%2Btrunk/
------------------------------------------------------------
revno: 6123 [merge]
revision-id: pqm at pqm.ubuntu.com-20110902083451-ucpbb8ydrk5upxwv
parent: pqm at pqm.ubuntu.com-20110901202114-g5ayj75lq9f47sch
parent: v.ladeuil+lp at free.fr-20110902062132-dq9y0slcwe28op73
committer: Canonical.com Patch Queue Manager <pqm at pqm.ubuntu.com>
branch nick: +trunk
timestamp: Fri 2011-09-02 08:34:51 +0000
message:
(vila) Start supporting option expansion in config stacks. (Vincent Ladeuil)
modified:
bzrlib/config.py config.py-20051011043216-070c74f4e9e338e8
bzrlib/tests/test_config.py testconfig.py-20051011041908-742d0c15d8d8c8eb
=== modified file 'bzrlib/config.py'
--- a/bzrlib/config.py 2011-08-30 12:40:00 +0000
+++ b/bzrlib/config.py 2011-09-02 08:34:51 +0000
@@ -75,7 +75,6 @@
import os
import string
import sys
-import re
from bzrlib.decorators import needs_write_lock
@@ -107,6 +106,7 @@
from bzrlib import (
commands,
hooks,
+ lazy_regex,
registry,
)
from bzrlib.symbol_versioning import (
@@ -2978,6 +2978,15 @@
class Stack(object):
"""A stack of configurations where an option can be defined"""
+ _option_ref_re = lazy_regex.lazy_compile('({[^{}]+})')
+ """Describes an exandable option reference.
+
+ We want to match the most embedded reference first.
+
+ I.e. for '{{foo}}' we will get '{foo}',
+ for '{bar{baz}}' we will get '{baz}'
+ """
+
def __init__(self, sections_def, store=None, mutable_section_name=None):
"""Creates a stack of sections with an optional store for changes.
@@ -2996,7 +3005,7 @@
self.store = store
self.mutable_section_name = mutable_section_name
- def get(self, name):
+ def get(self, name, expand=None):
"""Return the *first* option value found in the sections.
This is where we guarantee that sections coming from Store are loaded
@@ -3004,8 +3013,16 @@
option exists or get its value, which in turn may require to discover
in which sections it can be defined. Both of these (section and option
existence) require loading the store (even partially).
+
+ :param name: The queried option.
+
+ :param expand: Whether options references should be expanded.
+
+ :returns: The value of the option.
"""
# FIXME: No caching of options nor sections yet -- vila 20110503
+ if expand is None:
+ expand = _get_expand_default_value()
value = None
# Ensuring lazy loading is achieved by delaying section matching (which
# implies querying the persistent storage) until it can't be avoided
@@ -3030,16 +3047,152 @@
except KeyError:
# Not registered
opt = None
- if opt is not None:
+ if opt is not None and value is None:
+ # If the option is registered, it may provide a default value
+ value = opt.get_default()
+ if expand:
+ value = self._expand_option_value(value)
+ if opt is not None and value is not None:
value = opt.convert_from_unicode(value)
if value is None:
- # The conversion failed or there was no value to convert,
- # fallback to the default value
- value = opt.convert_from_unicode(opt.get_default())
+ # The conversion failed, fallback to the default value
+ value = opt.get_default()
+ if expand:
+ value = self._expand_option_value(value)
+ value = opt.convert_from_unicode(value)
for hook in ConfigHooks['get']:
hook(self, name, value)
return value
+ def _expand_option_value(self, value):
+ """Expand the option value depending on its type."""
+ if isinstance(value, list):
+ value = self._expand_options_in_list(value)
+ elif isinstance(value, dict):
+ trace.warning('Cannot expand "%s":'
+ ' Dicts do not support option expansion'
+ % (name,))
+ elif isinstance(value, (str, unicode)):
+ value = self._expand_options_in_string(value)
+ return value
+
+ def expand_options(self, string, env=None):
+ """Expand option references in the string in the configuration context.
+
+ :param string: The string containing option(s) to expand.
+
+ :param env: An option dict defining additional configuration options or
+ overriding existing ones.
+
+ :returns: The expanded string.
+ """
+ return self._expand_options_in_string(string, env)
+
+ def _expand_options_in_list(self, slist, env=None, _refs=None):
+ """Expand options in a list of strings in the configuration context.
+
+ :param slist: A list of strings.
+
+ :param env: An option dict defining additional configuration options or
+ overriding existing ones.
+
+ :param _refs: Private list (FIFO) containing the options being
+ expanded to detect loops.
+
+ :returns: The flatten list of expanded strings.
+ """
+ # expand options in each value separately flattening lists
+ result = []
+ for s in slist:
+ value = self._expand_options_in_string(s, env, _refs)
+ if isinstance(value, list):
+ result.extend(value)
+ else:
+ result.append(value)
+ return result
+
+ def _expand_options_in_string(self, string, env=None, _refs=None):
+ """Expand options in the string in the configuration context.
+
+ :param string: The string to be expanded.
+
+ :param env: An option dict defining additional configuration options or
+ overriding existing ones.
+
+ :param _refs: Private list (FIFO) containing the options being expanded
+ to detect loops.
+
+ :returns: The expanded string.
+ """
+ if string is None:
+ # Not much to expand there
+ return None
+ if _refs is None:
+ # What references are currently resolved (to detect loops)
+ _refs = []
+ result = string
+ # We need to iterate until no more refs appear ({{foo}} will need two
+ # iterations for example).
+ while True:
+ raw_chunks = Stack._option_ref_re.split(result)
+ if len(raw_chunks) == 1:
+ # Shorcut the trivial case: no refs
+ return result
+ chunks = []
+ list_value = False
+ # Split will isolate refs so that every other chunk is a ref
+ chunk_is_ref = False
+ for chunk in raw_chunks:
+ if not chunk_is_ref:
+ if chunk:
+ # Keep only non-empty strings (or we get bogus empty
+ # slots when a list value is involved).
+ chunks.append(chunk)
+ chunk_is_ref = True
+ else:
+ name = chunk[1:-1]
+ if name in _refs:
+ raise errors.OptionExpansionLoop(string, _refs)
+ _refs.append(name)
+ value = self._expand_option(name, env, _refs)
+ if value is None:
+ raise errors.ExpandingUnknownOption(name, string)
+ if isinstance(value, list):
+ list_value = True
+ chunks.extend(value)
+ else:
+ chunks.append(value)
+ _refs.pop()
+ chunk_is_ref = False
+ if list_value:
+ # Once a list appears as the result of an expansion, all
+ # callers will get a list result. This allows a consistent
+ # behavior even when some options in the expansion chain
+ # defined as strings (no comma in their value) but their
+ # expanded value is a list.
+ return self._expand_options_in_list(chunks, env, _refs)
+ else:
+ result = ''.join(chunks)
+ return result
+
+ def _expand_option(self, name, env, _refs):
+ if env is not None and name in env:
+ # Special case, values provided in env takes precedence over
+ # anything else
+ value = env[name]
+ else:
+ # FIXME: This is a limited implementation, what we really need is a
+ # way to query the bzr config for the value of an option,
+ # respecting the scope rules (That is, once we implement fallback
+ # configs, getting the option value should restart from the top
+ # config, not the current one) -- vila 20101222
+ value = self.get(name, expand=False)
+ if isinstance(value, list):
+ value = self._expand_options_in_list(value, env, _refs)
+ else:
+ value = self._expand_options_in_string(value, env, _refs)
+ return value
+
def _get_mutable_section(self):
"""Get the MutableSection for the Stack.
=== modified file 'bzrlib/tests/test_config.py'
--- a/bzrlib/tests/test_config.py 2011-08-30 12:40:00 +0000
+++ b/bzrlib/tests/test_config.py 2011-09-02 08:34:51 +0000
@@ -835,6 +835,7 @@
self.assertEquals(['{foo', '}', '{', 'bar}'],
conf.get_user_option('hidden', expand=True))
+
class TestLocationConfigOptionExpansion(tests.TestCaseInTempDir):
def get_config(self, location, string=None):
@@ -2387,6 +2388,100 @@
opt, [u'foo', u'1', u'True'])
+class TestOptionConverterMixin(object):
+
+ def assertConverted(self, expected, opt, value):
+ self.assertEquals(expected, opt.convert_from_unicode(value))
+
+ def assertWarns(self, opt, value):
+ warnings = []
+ def warning(*args):
+ warnings.append(args[0] % args[1:])
+ self.overrideAttr(trace, 'warning', warning)
+ self.assertEquals(None, opt.convert_from_unicode(value))
+ self.assertLength(1, warnings)
+ self.assertEquals(
+ 'Value "%s" is not valid for "%s"' % (value, opt.name),
+ warnings[0])
+
+ def assertErrors(self, opt, value):
+ self.assertRaises(errors.ConfigOptionValueError,
+ opt.convert_from_unicode, value)
+
+ def assertConvertInvalid(self, opt, invalid_value):
+ opt.invalid = None
+ self.assertEquals(None, opt.convert_from_unicode(invalid_value))
+ opt.invalid = 'warning'
+ self.assertWarns(opt, invalid_value)
+ opt.invalid = 'error'
+ self.assertErrors(opt, invalid_value)
+
+
+class TestOptionWithBooleanConverter(tests.TestCase, TestOptionConverterMixin):
+
+ def get_option(self):
+ return config.Option('foo', help='A boolean.',
+ from_unicode=config.bool_from_store)
+
+ def test_convert_invalid(self):
+ opt = self.get_option()
+ # A string that is not recognized as a boolean
+ self.assertConvertInvalid(opt, u'invalid-boolean')
+ # A list of strings is never recognized as a boolean
+ self.assertConvertInvalid(opt, [u'not', u'a', u'boolean'])
+
+ def test_convert_valid(self):
+ opt = self.get_option()
+ self.assertConverted(True, opt, u'True')
+ self.assertConverted(True, opt, u'1')
+ self.assertConverted(False, opt, u'False')
+
+
+class TestOptionWithIntegerConverter(tests.TestCase, TestOptionConverterMixin):
+
+ def get_option(self):
+ return config.Option('foo', help='An integer.',
+ from_unicode=config.int_from_store)
+
+ def test_convert_invalid(self):
+ opt = self.get_option()
+ # A string that is not recognized as an integer
+ self.assertConvertInvalid(opt, u'forty-two')
+ # A list of strings is never recognized as an integer
+ self.assertConvertInvalid(opt, [u'a', u'list'])
+
+ def test_convert_valid(self):
+ opt = self.get_option()
+ self.assertConverted(16, opt, u'16')
+
+
+class TestOptionWithListConverter(tests.TestCase, TestOptionConverterMixin):
+
+ def get_option(self):
+ return config.Option('foo', help='A list.',
+ from_unicode=config.list_from_store)
+
+ def test_convert_invalid(self):
+ # No string is invalid as all forms can be converted to a list
+ pass
+
+ def test_convert_valid(self):
+ opt = self.get_option()
+ # An empty string is an empty list
+ self.assertConverted([], opt, '') # Using a bare str() just in case
+ self.assertConverted([], opt, u'')
+ # A boolean
+ self.assertConverted([u'True'], opt, u'True')
+ # An integer
+ self.assertConverted([u'42'], opt, u'42')
+ # A single string
+ self.assertConverted([u'bar'], opt, u'bar')
+ # A list remains a list (configObj will turn a string containing commas
+ # into a list, but that's not what we're testing here)
+ self.assertConverted([u'foo', u'1', u'True'],
+ opt, [u'foo', u'1', u'True'])
+
+
class TestOptionRegistry(tests.TestCase):
def setUp(self):
@@ -3273,6 +3368,154 @@
self.assertEquals(['m', 'o', 'r', 'e'], self.conf.get('foo'))
+class TestStackExpandOptions(tests.TestCaseWithTransport):
+
+ def setUp(self):
+ super(TestStackExpandOptions, self).setUp()
+ self.overrideAttr(config, 'option_registry', config.OptionRegistry())
+ self.registry = config.option_registry
+ self.conf = build_branch_stack(self)
+
+ def assertExpansion(self, expected, string, env=None):
+ self.assertEquals(expected, self.conf.expand_options(string, env))
+
+ def test_no_expansion(self):
+ self.assertExpansion('foo', 'foo')
+
+ def test_expand_default_value(self):
+ self.conf.store._load_from_string('bar=baz')
+ self.registry.register(config.Option('foo', default=u'{bar}'))
+ self.assertEquals('baz', self.conf.get('foo', expand=True))
+
+ def test_expand_default_from_env(self):
+ self.conf.store._load_from_string('bar=baz')
+ self.registry.register(config.Option('foo', default_from_env=['FOO']))
+ self.overrideEnv('FOO', '{bar}')
+ self.assertEquals('baz', self.conf.get('foo', expand=True))
+
+ def test_expand_default_on_failed_conversion(self):
+ self.conf.store._load_from_string('baz=bogus\nbar=42\nfoo={baz}')
+ self.registry.register(
+ config.Option('foo', default=u'{bar}',
+ from_unicode=config.int_from_store))
+ self.assertEquals(42, self.conf.get('foo', expand=True))
+
+ def test_env_adding_options(self):
+ self.assertExpansion('bar', '{foo}', {'foo': 'bar'})
+
+ def test_env_overriding_options(self):
+ self.conf.store._load_from_string('foo=baz')
+ self.assertExpansion('bar', '{foo}', {'foo': 'bar'})
+
+ def test_simple_ref(self):
+ self.conf.store._load_from_string('foo=xxx')
+ self.assertExpansion('xxx', '{foo}')
+
+ def test_unknown_ref(self):
+ self.assertRaises(errors.ExpandingUnknownOption,
+ self.conf.expand_options, '{foo}')
+
+ def test_indirect_ref(self):
+ self.conf.store._load_from_string('''
+foo=xxx
+bar={foo}
+''')
+ self.assertExpansion('xxx', '{bar}')
+
+ def test_embedded_ref(self):
+ self.conf.store._load_from_string('''
+foo=xxx
+bar=foo
+''')
+ self.assertExpansion('xxx', '{{bar}}')
+
+ def test_simple_loop(self):
+ self.conf.store._load_from_string('foo={foo}')
+ self.assertRaises(errors.OptionExpansionLoop,
+ self.conf.expand_options, '{foo}')
+
+ def test_indirect_loop(self):
+ self.conf.store._load_from_string('''
+foo={bar}
+bar={baz}
+baz={foo}''')
+ e = self.assertRaises(errors.OptionExpansionLoop,
+ self.conf.expand_options, '{foo}')
+ self.assertEquals('foo->bar->baz', e.refs)
+ self.assertEquals('{foo}', e.string)
+
+ def test_list(self):
+ self.conf.store._load_from_string('''
+foo=start
+bar=middle
+baz=end
+list={foo},{bar},{baz}
+''')
+ self.assertEquals(['start', 'middle', 'end'],
+ self.conf.get('list', expand=True))
+
+ def test_cascading_list(self):
+ self.conf.store._load_from_string('''
+foo=start,{bar}
+bar=middle,{baz}
+baz=end
+list={foo}
+''')
+ self.assertEquals(['start', 'middle', 'end'],
+ self.conf.get('list', expand=True))
+
+ def test_pathologically_hidden_list(self):
+ self.conf.store._load_from_string('''
+foo=bin
+bar=go
+start={foo
+middle=},{
+end=bar}
+hidden={start}{middle}{end}
+''')
+ # Nope, it's either a string or a list, and the list wins as soon as a
+ # ',' appears, so the string concatenation never occur.
+ self.assertEquals(['{foo', '}', '{', 'bar}'],
+ self.conf.get('hidden', expand=True))
+
+
+class TestStackCrossSectionsExpand(tests.TestCaseWithTransport):
+
+ def setUp(self):
+ super(TestStackCrossSectionsExpand, self).setUp()
+
+ def get_config(self, location, string):
+ if string is None:
+ string = ''
+ # Since we don't save the config we won't strictly require to inherit
+ # from TestCaseInTempDir, but an error occurs so quickly...
+ c = config.LocationStack(location)
+ c.store._load_from_string(string)
+ return c
+
+ def test_dont_cross_unrelated_section(self):
+ c = self.get_config('/another/branch/path','''
+[/one/branch/path]
+foo = hello
+bar = {foo}/2
+
+[/another/branch/path]
+bar = {foo}/2
+''')
+ self.assertRaises(errors.ExpandingUnknownOption,
+ c.get, 'bar', expand=True)
+
+ def test_cross_related_sections(self):
+ c = self.get_config('/project/branch/path','''
+[/project]
+foo = qu
+
+[/project/branch/path]
+bar = {foo}ux
+''')
+ self.assertEquals('quux', c.get('bar', expand=True))
+
+
class TestStackSet(TestStackWithTransport):
def test_simple_set(self):
More information about the bazaar-commits
mailing list