Rev 5540: Merge config-stack into doc-new-config in file:///home/vila/src/bzr/experimental/config/

Vincent Ladeuil v.ladeuil+lp at free.fr
Thu Apr 7 07:44:14 UTC 2011


At file:///home/vila/src/bzr/experimental/config/

------------------------------------------------------------
revno: 5540 [merge]
revision-id: v.ladeuil+lp at free.fr-20110407074414-rsqszn9n24pr4ilg
parent: v.ladeuil+lp at free.fr-20110404092805-e50w1a213yq6esxz
parent: v.ladeuil+lp at free.fr-20110407074411-48xxlmuzz10j71d5
committer: Vincent Ladeuil <v.ladeuil+lp at free.fr>
branch nick: doc-new-config
timestamp: Thu 2011-04-07 09:44:14 +0200
message:
  Merge config-stack into doc-new-config
modified:
  bzrlib/config.py               config.py-20051011043216-070c74f4e9e338e8
  bzrlib/errors.py               errors.py-20050309040759-20512168c4e14fbd
  bzrlib/tests/test_config.py    testconfig.py-20051011041908-742d0c15d8d8c8eb
-------------- next part --------------
=== modified file 'bzrlib/config.py'
--- a/bzrlib/config.py	2011-04-04 09:28:00 +0000
+++ b/bzrlib/config.py	2011-04-07 07:44:11 +0000
@@ -848,7 +848,7 @@
         # LockableConfig for other kind of transports, we will need to reuse
         # whatever connection is already established -- vila 20100929
         self.transport = transport.get_transport(self.dir)
-        self._lock = lockdir.LockDir(self.transport, 'lock')
+        self._lock = lockdir.LockDir(self.transport, self.lock_name)
 
     def _create_from_string(self, unicode_bytes, save):
         super(LockableConfig, self)._create_from_string(unicode_bytes, False)
@@ -1988,7 +1988,6 @@
         self._transport.put_file(self._filename, out_file)
 
 
-# FIXME: This is not lazy yet -- vila 2011-04-01
 class ReadOnlySection(object):
     """A section defines a dict of options.
 
@@ -2030,24 +2029,255 @@
         del self.options[name]
 
 
+class Store(object):
+    """Abstract interface to persistent storage for configuration options."""
+
+    def __init__(self):
+        self.loaded = False
+
+    def load(self):
+        raise NotImplementedError(self.load)
+
+    def save(self):
+        raise NotImplementedError(self.save)
+
+    def get_sections(self):
+        """Returns an ordered iterable of existing sections.
+
+        :returns: An iterable of (name, dict).
+        """
+        raise NotImplementedError(self.get_sections)
+
+    def get_mutable_section(self, section_name=None):
+        """Returns the specified mutable section.
+
+        :param section_name: The section identifier
+        """
+        raise NotImplementedError(self.get_mutable_section)
+
+
+class ConfigObjStore(Store):
+
+    def __init__(self, transport, file_name):
+        """A config Store using ConfigObj for storage.
+
+        :param transport: The transport object where the config file is located.
+
+        :param file_name: The config file basename in the transport directory.
+        """
+        super(ConfigObjStore, self).__init__()
+        self.transport = transport
+        self.file_name = file_name
+        # No transient content is known initially
+        self._content = None
+
+    @classmethod
+    def from_string(cls, str_or_unicode, transport, file_name):
+        """Create a config store from a string.
+
+        :param str_or_unicode: A string representing the file content. This will
+            be utf-8 encoded internally.
+
+        :param transport: The transport object where the config file is located.
+
+        :param file_name: The configuration file basename.
+        """
+        conf = cls(transport=transport, file_name=file_name)
+        conf._create_from_string(str_or_unicode)
+        return conf
+
+    def _create_from_string(self, str_or_unicode):
+        # We just keep the content waiting for load() to be called when needed
+        self._content = StringIO(str_or_unicode.encode('utf-8'))
+
+    def load(self, allow_no_such_file=False):
+        """Load the store from the associated file.
+
+        :param allow_no_such_file: Swallow the NoSuchFile exception if True.
+            This allows delayed loading when creating the first option ever.
+        """
+        if self.loaded:
+            return
+        if self._content is not None:
+            co_input = self._content
+        else:
+            try:
+                content = self.transport.get_bytes(self.file_name)
+            except errors.NoSuchFile:
+                if allow_no_such_file:
+                    content = ''
+                else:
+                    raise
+            co_input =  StringIO(content)
+        try:
+            # The config files are always stored utf8-encoded
+            self._config_obj = ConfigObj(co_input, encoding='utf-8')
+        except configobj.ConfigObjError, e:
+            # FIXME: external_url should really accepts an optional relpath
+            # parameter (bug #750169) :-/ -- vila 2011-04-04
+            # The following will do in the interim but maybe we don't want to
+            # expose a path here but rather a config ID and its associated
+            # object </hand wawe>.
+            file_path = os.path.join(self.transport.external_url(),
+                                     self.file_name)
+            raise errors.ParseConfigError(e.errors, file_path)
+        self.loaded = True
+
+    def save(self):
+        out = StringIO()
+        self._config_obj.write(out)
+        self.transport.put_bytes(self.file_name, out.getvalue())
+        # We don't need the transient content anymore
+        self._content = None
+
+    def get_sections(self):
+        """Get the configobj section in the file order.
+
+        :returns: An iterable of (name, dict).
+        """
+        # We need a loaded store
+        self.load()
+        cobj = self._config_obj
+        if cobj.scalars:
+            yield ReadOnlySection(None, cobj)
+        for section_name in cobj.sections:
+            yield ReadOnlySection(section_name, cobj[section_name])
+
+    def get_mutable_section(self, section_name=None):
+        # We need a loaded store
+        self.load(allow_no_such_file=True)
+        if section_name is None:
+            section = self._config_obj
+        else:
+            section = self._config_obj.setdefault(section_name, {})
+        return MutableSection(section_name, section)
+
+
+class LockableConfigObjStore(ConfigObjStore):
+    """A ConfigObjStore using locks on save to ensure store integrity."""
+
+    def __init__(self, transport, file_name, lock_dir_name=None):
+        """A config Store using ConfigObj for storage.
+
+        :param transport: The transport object where the config file is located.
+
+        :param file_name: The config file basename in the transport directory.
+        """
+        if lock_dir_name is None:
+            lock_dir_name = 'lock'
+        self.lock_dir_name = lock_dir_name
+        super(LockableConfigObjStore, self).__init__(transport, file_name)
+        self._lock = lockdir.LockDir(self.transport, self.lock_dir_name)
+
+    def lock_write(self, token=None):
+        """Takes a write lock in the directory containing the config file.
+
+        If the directory doesn't exist it is created.
+        """
+        # FIXME: This doesn't check the ownership of the created directories as
+        # ensure_config_dir_exists does. It should if the transport is local
+        # -- vila 2011-04-06
+        self.transport.create_prefix()
+        return self._lock.lock_write(token)
+
+    def unlock(self):
+        self._lock.unlock()
+
+    def break_lock(self):
+        self._lock.break_lock()
+
+    @needs_write_lock
+    def save(self):
+        super(LockableConfigObjStore, self).save()
+
+
+# FIXME: global, bazaar, shouldn't that be 'user' instead or even
+# 'user_defaults' as opposed to 'user_overrides', 'system_defaults'
+# (/etc/bzr/bazaar.conf) and 'system_overrides' ? -- vila 2011-04-05
+class GlobalStore(LockableConfigObjStore):
+
+    def __init__(self, possible_transports=None):
+        t = transport.get_transport(config_dir(),
+                                    possible_transports=possible_transports)
+        super(GlobalStore, self).__init__(t, 'bazaar.conf')
+
+
+class LocationStore(LockableConfigObjStore):
+
+    def __init__(self, possible_transports=None):
+        t = transport.get_transport(config_dir(),
+                                    possible_transports=possible_transports)
+        super(LocationStore, self).__init__(transport, 'locations.conf')
+
+
+class BranchStore(ConfigObjStore):
+
+    def __init__(self, branch):
+        super(BranchStore, self).__init__(branch.control_transport,
+                                          'branch.conf')
+
+
 class ConfigStack(object):
     """A stack of configurations where an option can be defined"""
 
-    def __init__(self, config_list):
-        self.list = config_list
-        for c in self.list:
-            # Sanity check
-            if not hasattr(c, 'get'):
-                raise AssertionError("%r does not provide a 'get' method"
-                                     % (c,))
+    def __init__(self, sections=None, get_mutable_section=None):
+        """Creates a stack of sections with an optional store for changes.
+
+        :param sections: A list of ReadOnlySection or callables that returns an
+            iterable of ReadOnlySection.
+
+        :param get_mutable_section: A callable that returns a MutableSection
+            where changes are recorded.
+        """
+        if sections is None:
+            sections = []
+        self.sections = sections
+        self.get_mutable_section = get_mutable_section
 
     def get(self, name):
-        """Return the value from the first definition found in the list"""
-        for c in self.list:
-            value = c.get(name)
-            if value is not None:
-                break
-        return value
+        """Return the *first* option value found in the sections.
+
+        This is where we guarantee that sections coming from Store are loaded
+        lazily: the loading is delayed until we need to either check that an
+        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).
+        """
+        # Ensuring lazy loading is achieved by delaying section matching until
+        # it can be avoided anymore by using callables to describe (possibly
+        # empty) section lists.
+        for section_or_callable in self.sections:
+            # Each section can expand to multiple ones when a callable is used
+            if callable(section_or_callable):
+                sections = section_or_callable()
+            else:
+                sections = [section_or_callable]
+            for section in sections:
+                value = section.get(name)
+                if value is not None:
+                    return value
+        # No definition was found
+        return None
+
+    def set(self, name, value):
+        """Set a new value for the option.
+
+        This is where we guarantee that the mutable section is lazily loaded:
+        this means we won't load the corresponding store before setting a value.
+        """
+        section = self.get_mutable_section()
+        section.set(name, value)
+
+    def remove(self, name):
+        """Remove an existing option.
+
+        This is where we guarantee that the mutable section is lazily loaded:
+        this means we won't load the correspoding store before trying to delete
+        an option. In practice the store will often be loaded but this allows
+        catching some programming errors.
+        """
+        section = self.get_mutable_section()
+        section.remove(name)
 
 
 class cmd_config(commands.Command):

=== modified file 'bzrlib/errors.py'
--- a/bzrlib/errors.py	2011-03-12 23:58:55 +0000
+++ b/bzrlib/errors.py	2011-04-04 12:12:57 +0000
@@ -1766,12 +1766,12 @@
 
 class ParseConfigError(BzrError):
 
+    _fmt = "Error(s) parsing config file %(filename)s:\n%(errors)s"
+
     def __init__(self, errors, filename):
-        if filename is None:
-            filename = ""
-        message = "Error(s) parsing config file %s:\n%s" % \
-            (filename, ('\n'.join(e.msg for e in errors)))
-        BzrError.__init__(self, message)
+        BzrError.__init__(self)
+        self.filename = filename
+        self.errors = '\n'.join(e.msg for e in errors)
 
 
 class NoEmailInUsername(BzrError):

=== modified file 'bzrlib/tests/test_config.py'
--- a/bzrlib/tests/test_config.py	2011-04-04 09:28:00 +0000
+++ b/bzrlib/tests/test_config.py	2011-04-06 17:33:55 +0000
@@ -831,7 +831,7 @@
         def c1_write_config_file():
             before_writing.set()
             c1_orig()
-            # The lock is held we wait for the main thread to decide when to
+            # The lock is held. We wait for the main thread to decide when to
             # continue
             after_writing.wait()
         c1._write_config_file = c1_write_config_file
@@ -864,7 +864,7 @@
        c1_orig = c1._write_config_file
        def c1_write_config_file():
            ready_to_write.set()
-           # The lock is held we wait for the main thread to decide when to
+           # The lock is held. We wait for the main thread to decide when to
            # continue
            do_writing.wait()
            c1_orig()
@@ -1889,14 +1889,214 @@
         self.assertEquals(config._Created, section.orig['foo'])
 
 
+def get_ConfigObjStore(transport, file_name, content=None):
+    """Build a ConfigObjStore.
+
+    :param transport: The transport where the store lives.
+
+    :param file_name: The name of the store.
+
+    :param content: A provided content to inject into the built store.
+
+    If provided, the content is added to the store but without saving it on
+    disk. It should be a string or a unicode string in the ConfigObj syntax.
+    While this poses a constraint on other store implementations, it keeps a
+    simple syntax usable by test writers.
+    """
+    if content is None:
+        store = config.ConfigObjStore(transport, file_name)
+    else:
+        store = config.ConfigObjStore.from_string(content, transport, file_name)
+    return store
+
+
+class TestStore(tests.TestCaseWithTransport):
+
+    def assertSectionContent(self, expected, section):
+        """Assert that some options have the proper values in a section."""
+        expected_name, expected_options = expected
+        self.assertEquals(expected_name, section.id)
+        self.assertEquals(
+            expected_options,
+            dict([(k, section.get(k)) for k in expected_options.keys()]))
+
+
+class TestReadonlyStore(TestStore):
+
+    scenarios = [('configobj', {'_get_store': get_ConfigObjStore})]
+
+    def get_store(self, file_name, content=None):
+        return self._get_store(
+            self.get_readonly_transport(), file_name, content=content)
+
+    def test_delayed_load(self):
+        self.build_tree_contents([('foo.conf', '')])
+        store = self.get_store('foo.conf')
+        self.assertEquals(False, store.loaded)
+        store.load()
+        self.assertEquals(True, store.loaded)
+
+    def test_from_string_delayed_load(self):
+        store = self.get_store('foo.conf', '')
+        self.assertEquals(False, store.loaded)
+        store.load()
+        # We loaded the store from the provided content
+        self.assertEquals(True, store.loaded)
+
+    def test_get_no_sections_for_empty(self):
+        store = self.get_store('foo.conf', '')
+        store.load()
+        self.assertEquals([], list(store.get_sections()))
+
+    def test_get_default_section(self):
+        store = self.get_store('foo.conf', 'foo=bar')
+        sections = list(store.get_sections())
+        self.assertLength(1, sections)
+        self.assertSectionContent((None, {'foo': 'bar'}), sections[0])
+
+    def test_get_named_section(self):
+        store = self.get_store('foo.conf', '[baz]\nfoo=bar')
+        sections = list(store.get_sections())
+        self.assertLength(1, sections)
+        self.assertSectionContent(('baz', {'foo': 'bar'}), sections[0])
+
+
+class TestMutableStore(TestStore):
+
+    scenarios = [('configobj', {'_get_store': get_ConfigObjStore})]
+
+    def get_store(self, file_name, content=None):
+        return self._get_store(
+            self.get_transport(), file_name, content=content)
+
+    def test_save_empty_succeeds(self):
+        store = self.get_store('foo.conf', '')
+        store.load()
+        self.assertEquals(False, self.get_transport().has('foo.conf'))
+        store.save()
+        self.assertEquals(True, self.get_transport().has('foo.conf'))
+
+    def test_save_with_content_succeeds(self):
+        store = self.get_store('foo.conf', 'foo=bar\n')
+        store.load()
+        self.assertEquals(False, self.get_transport().has('foo.conf'))
+        store.save()
+        self.assertEquals(True, self.get_transport().has('foo.conf'))
+        modified_store = self.get_store('foo.conf')
+        sections = list(modified_store.get_sections())
+        self.assertLength(1, sections)
+        self.assertSectionContent((None, {'foo': 'bar'}), sections[0])
+
+    def test_set_option_in_empty_store(self):
+        store = self.get_store('foo.conf')
+        section = store.get_mutable_section(None)
+        section.set('foo', 'bar')
+        store.save()
+        modified_store = self.get_store('foo.conf')
+        sections = list(modified_store.get_sections())
+        self.assertLength(1, sections)
+        self.assertSectionContent((None, {'foo': 'bar'}), sections[0])
+
+    def test_set_option_in_default_section(self):
+        store = self.get_store('foo.conf', '')
+        section = store.get_mutable_section(None)
+        section.set('foo', 'bar')
+        store.save()
+        modified_store = self.get_store('foo.conf')
+        sections = list(modified_store.get_sections())
+        self.assertLength(1, sections)
+        self.assertSectionContent((None, {'foo': 'bar'}), sections[0])
+
+    def test_set_option_in_named_section(self):
+        store = self.get_store('foo.conf', '')
+        section = store.get_mutable_section('baz')
+        section.set('foo', 'bar')
+        store.save()
+        modified_store = self.get_store('foo.conf')
+        sections = list(modified_store.get_sections())
+        self.assertLength(1, sections)
+        self.assertSectionContent(('baz', {'foo': 'bar'}), sections[0])
+
+
+class TestConfigObjStore(TestStore):
+
+    def test_loading_unknown_file_fails(self):
+        store = config.ConfigObjStore(self.get_transport(), 'I-do-not-exist')
+        self.assertRaises(errors.NoSuchFile, store.load)
+
+    def test_invalid_content(self):
+        store = config.ConfigObjStore.from_string(
+            'this is invalid !', self.get_transport(), 'foo.conf', )
+        self.assertEquals(False, store.loaded)
+        exc = self.assertRaises(errors.ParseConfigError, store.load)
+        self.assertEndsWith(exc.filename, 'foo.conf')
+        # And the load failed
+        self.assertEquals(False, store.loaded)
+
+    def test_get_embedded_sections(self):
+        # A more complicated example (which also shows that section names and
+        # option names share the same name space...)
+        # FIXME: This should be fixed by forbidding dicts as values ?
+        # -- vila 2011-04-05
+        store = config.ConfigObjStore.from_string('''
+foo=bar
+l=1,2
+[DEFAULT]
+foo_in_DEFAULT=foo_DEFAULT
+[bar]
+foo_in_bar=barbar
+[baz]
+foo_in_baz=barbaz
+[[qux]]
+foo_in_qux=quux
+''', self.get_transport(), 'foo.conf')
+        sections = list(store.get_sections())
+        self.assertLength(4, sections)
+        # The default section has no name.
+        # List values are provided as lists
+        self.assertSectionContent((None, {'foo': 'bar', 'l': ['1', '2']}),
+                                  sections[0])
+        self.assertSectionContent(
+            ('DEFAULT', {'foo_in_DEFAULT': 'foo_DEFAULT'}), sections[1])
+        self.assertSectionContent(
+            ('bar', {'foo_in_bar': 'barbar'}), sections[2])
+        # sub sections are provided as embedded dicts.
+        self.assertSectionContent(
+            ('baz', {'foo_in_baz': 'barbaz', 'qux': {'foo_in_qux': 'quux'}}),
+            sections[3])
+
+
+class TestLockableConfigObjStore(TestStore):
+
+    def test_create_store_in_created_dir(self):
+        t = self.get_transport('dir/subdir')
+        store = config.LockableConfigObjStore(t, 'foo.conf')
+        store.get_mutable_section(None).set('foo', 'bar')
+        store.save()
+
+    # FIXME: We should adapt the tests in TestLockableConfig about concurrent
+    # writes, for now, I'll just rely on using the same code (copied, but
+    # pretty trivial) -- vila 20110-04-06
+
+
+class TestConfigObjStore(tests.TestCaseWithTransport):
+
+    def test_global_store(self):
+        store = config.GlobalStore()
+
+    def test_location_store(self):
+        store = config.LocationStore()
+
+    def test_branch_store(self):
+        b = self.make_branch('.')
+        store = config.BranchStore(b)
+
+
 class TestConfigStackGet(tests.TestCase):
 
     # FIXME: This should be parametrized for all known ConfigStack or dedicated
     # paramerized tests created to avoid bloating -- vila 2011-03-31
 
-    def test_compatibility(self):
-        self.assertRaises(AssertionError, config.ConfigStack, [object()])
-
     def test_single_config_get(self):
         conf = dict(foo='bar')
         conf_stack = config.ConfigStack([conf])
@@ -1914,6 +2114,60 @@
         conf_stack = config.ConfigStack([conf1, conf2])
         self.assertEquals('baz', conf_stack.get('foo'))
 
+    def test_get_for_empty_stack(self):
+        conf_stack = config.ConfigStack()
+        self.assertEquals(None, conf_stack.get('foo'))
+
+    def test_get_for_empty_section_callable(self):
+        conf_stack = config.ConfigStack([lambda : []])
+        self.assertEquals(None, conf_stack.get('foo'))
+
+
+class TestConfigStackSet(tests.TestCaseWithTransport):
+
+    # FIXME: This should be parametrized for all known ConfigStack or dedicated
+    # paramerized tests created to avoid bloating -- vila 2011-04-05
+
+    def test_simple_set(self):
+        store = config.ConfigObjStore.from_string(
+            'foo=bar', self.get_transport(), 'test.conf')
+        conf = config.ConfigStack(
+            [store.get_sections], store.get_mutable_section)
+        self.assertEquals('bar', conf.get('foo'))
+        conf.set('foo', 'baz')
+        # Did we get it back ?
+        self.assertEquals('baz', conf.get('foo'))
+
+    def test_set_creates_a_new_section(self):
+        store = config.ConfigObjStore.from_string(
+            '', self.get_transport(), 'test.conf')
+        conf = config.ConfigStack(
+            [store.get_sections], store.get_mutable_section)
+        conf.set('foo', 'baz')
+        self.assertEquals, 'baz', conf.get('foo')
+
+
+class TestConfigStackRemove(tests.TestCaseWithTransport):
+
+    # FIXME: This should be parametrized for all known ConfigStack or dedicated
+    # paramerized tests created to avoid bloating -- vila 2011-04-06
+
+    def test_remove_existing(self):
+        store = config.ConfigObjStore.from_string(
+            'foo=bar', self.get_transport(), 'test.conf')
+        conf = config.ConfigStack(
+            [store.get_sections], store.get_mutable_section)
+        self.assertEquals('bar', conf.get('foo'))
+        conf.remove('foo')
+        # Did we get it back ?
+        self.assertEquals(None, conf.get('foo'))
+
+    def test_remove_unknown(self):
+        store = config.ConfigObjStore(self.get_transport(), 'test.conf')
+        conf = config.ConfigStack(
+            [store.get_sections], store.get_mutable_section)
+        self.assertRaises(KeyError, conf.remove, 'I_do_not_exist')
+
 
 class TestConfigGetOptions(tests.TestCaseWithTransport, TestOptionsMixin):
 



More information about the bazaar-commits mailing list