Patchwork [1,of,2,RFC] RFC: implement immutable config objects

login
register
mail settings
Submitter Jun Wu
Date March 27, 2017, 6:38 p.m.
Message ID <4eb7c76340791f379a34.1490639886@localhost.localdomain>
Download mbox | patch
Permalink /patch/19760/
State Changes Requested
Headers show

Comments

Jun Wu - March 27, 2017, 6:38 p.m.
# HG changeset patch
# User Jun Wu <quark@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.
David Soria Parra - March 29, 2017, 10:25 p.m.
On Mon, Mar 27, 2017 at 11:38:06AM -0700, Jun Wu wrote:
> # HG changeset patch
> # User Jun Wu <quark@fb.com>
> # Date 1490635856 25200
> #      Mon Mar 27 10:30:56 2017 -0700
> # Node ID 4eb7c76340791f379a34f9df4ec42e0c8b9b2a2f
> # Parent  4a8d065bbad80d3b3401010375bc80165404aa87
> RFC: implement immutable config objects

I like the overall approach and that it's parallel to config.config() and
ui.config. We might in later patches combine config.config into this.

> +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()
Can we use collections.defaultdict(util.sortdict) here?

> +        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):
I am not sure about this API. We already have getsection() from
abstractimmutableconfig. I think this method does two things depending
on the arguments.
> +        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()
Could we do an `items.update(subconfig.getsection(section))`?
> +                    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):
I think this is actually not needed except for dumpconfig, which might just be a
helper that knows about the internals.

> +        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__'):
this should be `callable`

> +                items = filter(self._subconfig.getsection(section))
> +            else:
> +                items = filter
> +            self._cachedsections[section] = items
> +        return items
Jun Wu - March 29, 2017, 10:43 p.m.
Excerpts from David Soria Parra's message of 2017-03-29 15:25:26 -0700:
> On Mon, Mar 27, 2017 at 11:38:06AM -0700, Jun Wu wrote:
> > # HG changeset patch
> > # User Jun Wu <quark@fb.com>
> > # Date 1490635856 25200
> > #      Mon Mar 27 10:30:56 2017 -0700
> > # Node ID 4eb7c76340791f379a34f9df4ec42e0c8b9b2a2f
> > # Parent  4a8d065bbad80d3b3401010375bc80165404aa87
> > RFC: implement immutable config objects
> 
> I like the overall approach and that it's parallel to config.config() and
> ui.config. We might in later patches combine config.config into this.

If we can replace "x.setconfig(...)" to "x = x.setconfig(...)", and make
"config.config" immutable too, the path is worth a try. Practically, that
may lead to BCs.


> > +        super(atomicconfig, self).__init__(title)
> > +        self._sections = util.sortdict()
> Can we use collections.defaultdict(util.sortdict) here?

Good advice.

> > +
> > +    def subconfigs(self, section=None):
> I am not sure about this API. We already have getsection() from
> abstractimmutableconfig. I think this method does two things depending
> on the arguments.

It was "subconfigs(self)", the "section" was added for performance. I'll
revisit if it's still needed.

I think eventually we want "debugconfig / config" to be able to get the
layered information too. So it can show "config X" set by "file Y" is
shallowed by "file Z". Or it can draw a tree.

Initially, I was thinking this like a React DOM, a diff algorithm to compare
the DOM tree, and replace elements in it. That makes "subconfigs" useful.
Later I decided the diff algorithm is a overkill.

The same reason applies to why I make "title" a required parameter. So
things like "get-subconfig-by-title" is possible.

Since we now control the subconfigs directly and build the root config on
demand, we never need to get subconfigs from the root config, so the API is
unnecessary. I'll make them private in the next version.

> > +                items = util.sortdict()
> > +                for subconfig in subconfigs:
> > +                    subconfigitems = subconfig.getsection(section).items()
> Could we do an `items.update(subconfig.getsection(section))`?

Sure.

> > +    def subconfigs(self, section=None):
> I think this is actually not needed except for dumpconfig, which might just be a
> helper that knows about the internals.
> 
> > +        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__'):
> this should be `callable`
> 
> > +                items = filter(self._subconfig.getsection(section))
> > +            else:
> > +                items = filter
> > +            self._cachedsections[section] = items
> > +        return items
Yuya Nishihara - April 1, 2017, 2:39 p.m.
On Wed, 29 Mar 2017 15:25:26 -0700, David Soria Parra wrote:
> On Mon, Mar 27, 2017 at 11:38:06AM -0700, Jun Wu wrote:
> > # HG changeset patch
> > # User Jun Wu <quark@fb.com>
> > # Date 1490635856 25200
> > #      Mon Mar 27 10:30:56 2017 -0700
> > # Node ID 4eb7c76340791f379a34f9df4ec42e0c8b9b2a2f
> > # Parent  4a8d065bbad80d3b3401010375bc80165404aa87
> > RFC: implement immutable config objects
> 
> I like the overall approach and that it's parallel to config.config() and
> ui.config. We might in later patches combine config.config into this.

Yea, the idea sounds good and the next patch proves that this actually works.
Nice.

Random nitpicks in line.

> > +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))

We might want to keep filename and line number separately in future version.

> > +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
> > +        """

So this is more like "map" operation? I thought filterfunc would return
booleans.

> > +        super(filteredconfig, self).__init__(title)
> > +        self._filters = filters or {}
> > +        self._subconfig = subconfig
> > +        self._cachedsections = {}
> > +
> > +    def subconfigs(self, section=None):
> I think this is actually not needed except for dumpconfig, which might just be a
> helper that knows about the internals.
> 
> > +        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__'):
> this should be `callable`
> 
> > +                items = filter(self._subconfig.getsection(section))
> > +            else:
> > +                items = filter

Do we really need to overload the type of filter?
Jun Wu - April 1, 2017, 10:56 p.m.
Excerpts from Yuya Nishihara's message of 2017-04-01 23:39:34 +0900:
> Yea, the idea sounds good and the next patch proves that this actually works.
> Nice.

Thanks.

> 
> Random nitpicks in line.
> 
> > > +                    yield (section, item, (value, source))
> 
> We might want to keep filename and line number separately in future version.

Since there are other sources like "setconfig", "--config", "--verbose", and
the new "$ENVNAME", maybe "source" needs to a tuple or something more
complex / typed. But it's probably another series.

The immutable configs (other than fileconfig) do not assume the format of
the tuple "(section, item, value)". So if we do change it to "(section,
item, (value, ('file', path, line)))", other objects won't need change.

> > > +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
> > > +        """
> 
> So this is more like "map" operation? I thought filterfunc would return
> booleans.

It should probably be renamed to "transformfunc".

> > > +            if util.safehasattr(filter, '__call__'):
> > this should be `callable`
> > 
> > > +                items = filter(self._subconfig.getsection(section))
> > > +            else:
> > > +                items = filter
> 
> Do we really need to overload the type of filter?

Good point. The "transform" function could take a section:

    def transform(section, items):
        ....

So the "static items" type is no longer needed.

I wrote that mainly for performance consideration, and it seems unnecessary.
Yuya Nishihara - April 2, 2017, 5:08 a.m.
On Sat, 1 Apr 2017 15:56:25 -0700, Jun Wu wrote:
> Excerpts from Yuya Nishihara's message of 2017-04-01 23:39:34 +0900:
> > > > +                    yield (section, item, (value, source))
> > 
> > We might want to keep filename and line number separately in future version.
> 
> Since there are other sources like "setconfig", "--config", "--verbose", and
> the new "$ENVNAME", maybe "source" needs to a tuple or something more
> complex / typed. But it's probably another series.

Yeah, that's kinda unrelated topic to the core functions in this series.
I just recalled someone (Mathias?) wanted to get source filenames.

> The immutable configs (other than fileconfig) do not assume the format of
> the tuple "(section, item, value)". So if we do change it to "(section,
> item, (value, ('file', path, line)))", other objects won't need change.

> > > > +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
> > > > +        """
> > 
> > So this is more like "map" operation? I thought filterfunc would return
> > booleans.
> 
> It should probably be renamed to "transformfunc".

and "transformedconfig" respectively, maybe.
Mathias De Maré - April 3, 2017, 6:37 a.m.
On 02-04-17 07:08, Yuya Nishihara wrote:
> On Sat, 1 Apr 2017 15:56:25 -0700, Jun Wu wrote:
>> Excerpts from Yuya Nishihara's message of 2017-04-01 23:39:34 +0900:
>>>>> +                    yield (section, item, (value, source))
>>> We might want to keep filename and line number separately in future version.
>> Since there are other sources like "setconfig", "--config", "--verbose", and
>> the new "$ENVNAME", maybe "source" needs to a tuple or something more
>> complex / typed. But it's probably another series.
> Yeah, that's kinda unrelated topic to the core functions in this series.
> I just recalled someone (Mathias?) wanted to get source filenames.
That's correct, I would be quite interested in that for hg-configexpress.

Patch

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
+