[PATCH 1 of 2 RFC] RFC: implement immutable config objects

Jun Wu quark at fb.com
Mon Mar 27 14:38:06 EDT 2017


# HG changeset patch
# User Jun Wu <quark at fb.com>
# Date 1490635856 25200
#      Mon Mar 27 10:30:56 2017 -0700
# Node ID 4eb7c76340791f379a34f9df4ec42e0c8b9b2a2f
# Parent  4a8d065bbad80d3b3401010375bc80165404aa87
RFC: implement immutable config objects

The immutable config objects are basic data structures representating
configs.

This approach highlights:

  - Config layers. Previously there is little layer support, if an extension
    calls "setconfig", you lose what the config was before "setconfig".
    That's part of chg's compatibilities with some extensions (evolve).
    With layers, new possibilities like "inserting a layer later"
    (ui.compat), "detect system extensions overridden by user hgrc" will be
    possible.
  - Fast cache invalidation test. The invalidation test is just to compare
    object ids. It would it affordable to remove states like "debugflag",
    "verbose", "trustedusers", "trustedgroups", and just use the config as
    the source of truth. It also means we can get rid of "ui.fixconfig".

In general, more flexible and more confidence.

It's RFC and not splitted, to make it easier to get the whole picture.

diff --git a/mercurial/config.py b/mercurial/config.py
--- a/mercurial/config.py
+++ b/mercurial/config.py
@@ -263,2 +263,179 @@ def parselist(value):
         result = value
     return result or []
+
+class abstractimmutableconfig(object):
+    """minimal interface defined for a read-only config accessor
+
+    The immutable config object should get its state set and frozen during
+    __init__ and should not have any setconfig-like method.
+
+    The immutable config layer knows about sections, and should probably uses
+    ordered dict for each section. To simplify things, this layer does not care
+    about "source", "unset" or any filesystem IO. They're up to the upper layer
+    to deal with. For example, the upper layer could store "None" as "unset"s,
+    and store (value, source) as a tuple together.
+    """
+
+    def __init__(self, title):
+        """title: useful to identify the config"""
+        self.title = title
+
+    def subconfigs(self, section=None):
+        """return a list-ish of child config objects filtered by section"""
+        raise NotImplementedError
+
+    def get(self, section, item):
+        """return value or None"""
+        return self.getsection(section).get(item)
+
+    def getsection(self, section):
+        """return a dict-ish"""
+        raise NotImplementedError
+
+    def sections(self):
+        """return an iter-able"""
+        raise NotImplementedError
+
+class atomicconfig(abstractimmutableconfig):
+    """immutable config that converted from a list-ish"""
+
+    def __init__(self, title, entries=None):
+        """
+        title:   a title used to identify the atomicconfig
+        entries: a list-ish of (section, item, value)
+        """
+        super(atomicconfig, self).__init__(title)
+        self._sections = util.sortdict()
+        for entry in entries:
+            section, item, value = entry
+            if section not in self._sections:
+                self._sections[section] = util.sortdict()
+            if item is not None:
+                self._sections[section][item] = value
+
+    def subconfigs(self, section=None):
+        return ()
+
+    def getsection(self, section):
+        return self._sections.get(section, {})
+
+    def sections(self):
+        return self._sections.keys()
+
+class mergedconfig(abstractimmutableconfig):
+    """immutable config that is a merge of a list of immutable configs"""
+
+    def __init__(self, title, subconfigs):
+        super(mergedconfig, self).__init__(title)
+        self._subconfigs = tuple(subconfigs)  # make it immutable
+        self._cachedsections = {}
+
+    def subconfigs(self, section=None):
+        if section is None:
+            return self._subconfigs
+        else:
+            return self._sectionconfigs.get(section, ())
+
+    def sections(self):
+        return self._sectionconfigs.keys()
+
+    @util.propertycache
+    def _sectionconfigs(self):
+        """{section: [subconfig]}"""
+        sectionconfigs = {}
+        for subconfig in self._subconfigs:
+            for section in subconfig.sections():
+                sectionconfigs.setdefault(section, []).append(subconfig)
+        return sectionconfigs
+
+    def getsection(self, section):
+        items = self._cachedsections.get(section, None)
+        if items is None:
+            subconfigs = self._sectionconfigs.get(section, [])
+            if len(subconfigs) == 1:
+                # no need to merge configs
+                items = subconfigs[0].getsection(section)
+            else:
+                # merge configs
+                items = util.sortdict()
+                for subconfig in subconfigs:
+                    subconfigitems = subconfig.getsection(section).items()
+                    for item, value in subconfigitems:
+                        items[item] = value
+            self._cachedsections[section] = items
+        return items
+
+    def append(self, subconfig):
+        """return a new mergedconfig with the new subconfig appended"""
+        return mergedconfig(self.title, list(self._subconfigs) + [subconfig])
+
+    def prepend(self, subconfig):
+        """return a new mergedconfig with the new subconfig prepended"""
+        return mergedconfig(self.title, [subconfig] + list(self._subconfigs))
+
+    def filter(self, func):
+        """return a new mergedconfig with only selected subconfigs
+        func: subconfig -> bool
+        """
+        return mergedconfig(self.title, filter(func, self._subconfigs))
+
+class fileconfig(atomicconfig):
+    """immutable config constructed from config files"""
+
+    def __init__(self, title, fp, sections=None, remap=None):
+        # Currently, just use the legacy, non-immutable "config" object to do
+        # the parsing, remap stuff. In the future we may want to detach from it
+        cfg = config()
+        cfg.read(title, fp, sections=sections, remap=remap)
+
+        def cfgwalker():
+            # visible config, with source
+            for section in cfg:
+                emptysection = True
+                for item, value in cfg.items(section):
+                    emptysection = False
+                    source = cfg.source(section, item)
+                    yield (section, item, (value, source))
+                if emptysection:
+                    # create the section
+                    yield (section, None, (None, None))
+            # "%unset" configs
+            for (section, item) in cfg._unset:
+                # TODO "%unset" could have line numbers
+                if cfg.get(section, item) is None:
+                    yield (section, item, (None, '%s:(unset)' % title))
+
+        super(fileconfig, self).__init__(title, cfgwalker())
+
+class filteredconfig(abstractimmutableconfig):
+    """immutable config that changes other configs"""
+
+    def __init__(self, title, subconfig, filters=None):
+        """
+        filters: a dict-ish, {section: filterfunc or sortdict-ish}
+                 a filterfunc takes sortdict-ish and returns sortdict-ish
+                 a sortdict-ish will replace the section directly
+        """
+        super(filteredconfig, self).__init__(title)
+        self._filters = filters or {}
+        self._subconfig = subconfig
+        self._cachedsections = {}
+
+    def subconfigs(self, section=None):
+        return (self._subconfig,)
+
+    def sections(self):
+        return self._subconfig.sections()
+
+    def getsection(self, section):
+        if section not in self._filters:
+            return self._subconfig.getsection(section)
+        items = self._cachedsections.get(section, None)
+        if items is None:
+            filter = self._filters[section]
+            if util.safehasattr(filter, '__call__'):
+                items = filter(self._subconfig.getsection(section))
+            else:
+                items = filter
+            self._cachedsections[section] = items
+        return items
diff --git a/tests/test-config-immutable.py b/tests/test-config-immutable.py
new file mode 100644
--- /dev/null
+++ b/tests/test-config-immutable.py
@@ -0,0 +1,94 @@
+# Test the config layer generated by environment variables
+
+from __future__ import absolute_import, print_function
+
+from mercurial import (
+    config,
+    util,
+)
+
+def dumpconfig(config, indent=0):
+    print('%s%s (%s)' % (' ' * indent, config.title, config.__class__.__name__))
+    sections = sorted(config.sections())
+    for section in sections:
+        items = config.getsection(section)
+        for name, (value, source) in items.iteritems():
+            print('%s  %s.%s=%s # %s'
+                  % (' ' * indent, section, name, value, source))
+    subconfigs = config.subconfigs()
+    if subconfigs:
+        for subc in subconfigs:
+            dumpconfig(subc, indent + 2)
+    if indent == 0:
+        print('')
+
+# atomicconfig
+
+c1 = config.atomicconfig('c1', [
+    ['ui', 'editor', ('notepad', 'rc1:1')],
+    ['ui', 'editor', ('vim', 'rc1:2')],
+    ['ui', 'traceback', ('1', 'rc1:3')],
+    ['paths', 'default', ('none', 'rc2:1')],
+    ['pager', 'pager', (None, 'rc3:1')],
+    ['diff', 'git', ('1', 'rc3:2')],
+])
+dumpconfig(c1)
+
+c2 = config.atomicconfig('c2', [
+    ['pager', 'pager', ('more', 'rc5:1')],
+    ['smtp', 'tls', ('True', 'rc5:2')],
+    ['ui', 'traceback', (None, 'rc4:1')],
+    ['paths', 'remote', ('ssh://foo/bar', 'rc6:1')],
+    ['ui', 'editor', ('emacs', 'rc4:2')],
+])
+dumpconfig(c2)
+
+# mergedconfig
+
+m1 = config.mergedconfig('c1 + c2', [c1, c2])
+dumpconfig(m1)
+
+m2 = config.mergedconfig('c2 + c1', [c2, c1])
+dumpconfig(m2)
+
+# filteredconfig
+
+def filterpath(items):
+    result = util.sortdict()
+    for name, (value, source) in items.items():
+        result[name] = (value.replace('ssh', 'http'), source)
+    return result
+
+filters = {'paths': filterpath,
+           'diff': {'git': ('0', 'f')},
+           'smtp': {'tls': ('False', 'f')},
+           'ui': {}}
+
+f1 = config.filteredconfig('f1', c2, filters)
+dumpconfig(f1)
+
+# fileconfig
+
+sio = util.stringio()
+rc = '''
+[ui]
+editor = nano
+debug = 1
+
+[pager]
+%unset pager
+
+[diff]
+unified = 2
+'''
+sio.write(rc)
+sio.reset()
+
+r1 = config.fileconfig('hgrc', sio)
+dumpconfig(r1)
+
+# complex example
+
+root = config.mergedconfig('root', [m1, f1, r1])
+dumpconfig(root)
+
diff --git a/tests/test-config-immutable.py.out b/tests/test-config-immutable.py.out
new file mode 100644
--- /dev/null
+++ b/tests/test-config-immutable.py.out
@@ -0,0 +1,119 @@
+c1 (atomicconfig)
+  diff.git=1 # rc3:2
+  pager.pager=None # rc3:1
+  paths.default=none # rc2:1
+  ui.editor=vim # rc1:2
+  ui.traceback=1 # rc1:3
+
+c2 (atomicconfig)
+  pager.pager=more # rc5:1
+  paths.remote=ssh://foo/bar # rc6:1
+  smtp.tls=True # rc5:2
+  ui.traceback=None # rc4:1
+  ui.editor=emacs # rc4:2
+
+c1 + c2 (mergedconfig)
+  diff.git=1 # rc3:2
+  pager.pager=more # rc5:1
+  paths.default=none # rc2:1
+  paths.remote=ssh://foo/bar # rc6:1
+  smtp.tls=True # rc5:2
+  ui.traceback=None # rc4:1
+  ui.editor=emacs # rc4:2
+  c1 (atomicconfig)
+    diff.git=1 # rc3:2
+    pager.pager=None # rc3:1
+    paths.default=none # rc2:1
+    ui.editor=vim # rc1:2
+    ui.traceback=1 # rc1:3
+  c2 (atomicconfig)
+    pager.pager=more # rc5:1
+    paths.remote=ssh://foo/bar # rc6:1
+    smtp.tls=True # rc5:2
+    ui.traceback=None # rc4:1
+    ui.editor=emacs # rc4:2
+
+c2 + c1 (mergedconfig)
+  diff.git=1 # rc3:2
+  pager.pager=None # rc3:1
+  paths.remote=ssh://foo/bar # rc6:1
+  paths.default=none # rc2:1
+  smtp.tls=True # rc5:2
+  ui.editor=vim # rc1:2
+  ui.traceback=1 # rc1:3
+  c2 (atomicconfig)
+    pager.pager=more # rc5:1
+    paths.remote=ssh://foo/bar # rc6:1
+    smtp.tls=True # rc5:2
+    ui.traceback=None # rc4:1
+    ui.editor=emacs # rc4:2
+  c1 (atomicconfig)
+    diff.git=1 # rc3:2
+    pager.pager=None # rc3:1
+    paths.default=none # rc2:1
+    ui.editor=vim # rc1:2
+    ui.traceback=1 # rc1:3
+
+f1 (filteredconfig)
+  pager.pager=more # rc5:1
+  paths.remote=http://foo/bar # rc6:1
+  smtp.tls=False # f
+  c2 (atomicconfig)
+    pager.pager=more # rc5:1
+    paths.remote=ssh://foo/bar # rc6:1
+    smtp.tls=True # rc5:2
+    ui.traceback=None # rc4:1
+    ui.editor=emacs # rc4:2
+
+hgrc (fileconfig)
+  diff.unified=2 # hgrc:10
+  pager.pager=None # hgrc:(unset)
+  ui.editor=nano # hgrc:3
+  ui.debug=1 # hgrc:4
+
+root (mergedconfig)
+  diff.git=1 # rc3:2
+  diff.unified=2 # hgrc:10
+  pager.pager=None # hgrc:(unset)
+  paths.default=none # rc2:1
+  paths.remote=http://foo/bar # rc6:1
+  smtp.tls=False # f
+  ui.traceback=None # rc4:1
+  ui.editor=nano # hgrc:3
+  ui.debug=1 # hgrc:4
+  c1 + c2 (mergedconfig)
+    diff.git=1 # rc3:2
+    pager.pager=more # rc5:1
+    paths.default=none # rc2:1
+    paths.remote=ssh://foo/bar # rc6:1
+    smtp.tls=True # rc5:2
+    ui.traceback=None # rc4:1
+    ui.editor=emacs # rc4:2
+    c1 (atomicconfig)
+      diff.git=1 # rc3:2
+      pager.pager=None # rc3:1
+      paths.default=none # rc2:1
+      ui.editor=vim # rc1:2
+      ui.traceback=1 # rc1:3
+    c2 (atomicconfig)
+      pager.pager=more # rc5:1
+      paths.remote=ssh://foo/bar # rc6:1
+      smtp.tls=True # rc5:2
+      ui.traceback=None # rc4:1
+      ui.editor=emacs # rc4:2
+  f1 (filteredconfig)
+    pager.pager=more # rc5:1
+    paths.remote=http://foo/bar # rc6:1
+    smtp.tls=False # f
+    c2 (atomicconfig)
+      pager.pager=more # rc5:1
+      paths.remote=ssh://foo/bar # rc6:1
+      smtp.tls=True # rc5:2
+      ui.traceback=None # rc4:1
+      ui.editor=emacs # rc4:2
+  hgrc (fileconfig)
+    diff.unified=2 # hgrc:10
+    pager.pager=None # hgrc:(unset)
+    ui.editor=nano # hgrc:3
+    ui.debug=1 # hgrc:4
+


More information about the Mercurial-devel mailing list