Browse Source

The config parser now supports the git-config file format as
described in git-config(1) and can write git config files.

Jelmer Vernooij 13 years ago
parent
commit
73d767c12e
6 changed files with 588 additions and 12 deletions
  1. 6 0
      NEWS
  2. 333 0
      dulwich/config.py
  3. 18 10
      dulwich/repo.py
  4. 1 0
      dulwich/tests/__init__.py
  5. 226 0
      dulwich/tests/test_config.py
  6. 4 2
      dulwich/tests/test_repository.py

+ 6 - 0
NEWS

@@ -1,5 +1,11 @@
 0.8.3	UNRELEASED
 
+ FEATURES
+
+  * The config parser now supports the git-config file format as
+    described in git-config(1) and can write git config files.
+    (Jelmer Vernooij, #531092)
+
 0.8.2	2011-12-18
 
  BUG FIXES

+ 333 - 0
dulwich/config.py

@@ -0,0 +1,333 @@
+# config.py - Reading and writing Git config files
+# Copyright (C) 2011 Jelmer Vernooij <jelmer@samba.org>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; version 2
+# of the License or (at your option) a later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+# MA  02110-1301, USA.
+
+"""Reading and writing Git configuration files.
+
+TODO:
+ * preserve formatting when updating configuration files
+ * treat subsection names as case-insensitive for [branch.foo] style
+   subsections
+"""
+
+import errno
+import os
+import re
+
+from dulwich.file import GitFile
+
+
+class Config(object):
+    """A Git configuration."""
+
+    def get(self, section, name):
+        """Retrieve the contents of a configuration setting.
+        
+        :param section: Tuple with section name and optional subsection namee
+        :param subsection: Subsection name
+        :return: Contents of the setting
+        :raise KeyError: if the value is not set
+        """
+        raise NotImplementedError(self.get)
+
+    def get_boolean(self, name):
+        """Retrieve a configuration setting as boolean.
+
+        :parma name: Name of the setting, including section and possible
+            subsection.
+        :return: Contents of the setting
+        :raise KeyError: if the value is not set
+        """
+        return bool(self.get(name))
+
+    def set(self, section, name, value):
+        """Set a configuration value.
+        
+        :param name: Name of the configuration value, including section
+            and optional subsection
+        :param: Value of the setting
+        """
+        raise NotImplementedError(self.set)
+
+
+class ConfigDict(Config):
+    """Git configuration stored in a dictionary."""
+
+    def __init__(self, values=None):
+        """Create a new ConfigDict."""
+        if values is None:
+            values = {}
+        self._values = values
+
+    def __repr__(self):
+        return "%s(%r)" % (self.__class__.__name__, self._values)
+
+    def __eq__(self, other):
+        return (
+            isinstance(other, self.__class__) and
+            other._values == self._values)
+
+    @classmethod
+    def _parse_setting(cls, name):
+        parts = name.split(".")
+        if len(parts) == 3:
+            return (parts[0], parts[1], parts[2])
+        else:
+            return (parts[0], None, parts[1])
+
+    def get(self, section, name):
+        if isinstance(section, basestring):
+            section = (section, )
+        if len(section) > 1:
+            try:
+                return self._values[section][name]
+            except KeyError:
+                pass
+        return self._values[(section[0],)][name]
+
+    def set(self, section, name, value):
+        if isinstance(section, basestring):
+            section = (section, )
+        self._values.setdefault(section, {})[name] = value
+
+
+def _format_string(value):
+    if (value.startswith(" ") or
+        value.startswith("\t") or
+        value.endswith(" ") or
+        value.endswith("\t")):
+        return '"%s"' % _escape_value(value)
+    return _escape_value(value)
+
+
+def _parse_string(value):
+    value = value.strip()
+    ret = []
+    block = []
+    in_quotes  = False
+    for c in value:
+        if c == "\"":
+            in_quotes = (not in_quotes)
+            ret.append(_unescape_value("".join(block)))
+            block = []
+        elif c in ("#", ";") and not in_quotes:
+            # the rest of the line is a comment
+            break
+        else:
+            block.append(c)
+
+    if in_quotes:
+        raise ValueError("value starts with quote but lacks end quote")
+
+    ret.append(_unescape_value("".join(block)).rstrip())
+
+    return "".join(ret)
+
+
+def _unescape_value(value):
+    """Unescape a value."""
+    def unescape(c):
+        return {
+            "\\\\": "\\",
+            "\\\"": "\"",
+            "\\n": "\n",
+            "\\t": "\t",
+            "\\b": "\b",
+            }[c.group(0)]
+    return re.sub(r"(\\.)", unescape, value)
+
+
+def _escape_value(value):
+    """Escape a value."""
+    return value.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t").replace("\"", "\\\"")
+
+
+def _check_variable_name(name):
+    for c in name:
+        if not c.isalnum() and c != '-':
+            return False
+    return True
+
+
+def _check_section_name(name):
+    for c in name:
+        if not c.isalnum() and c not in ('-', '.'):
+            return False
+    return True
+
+
+def _strip_comments(line):
+    line = line.split("#")[0]
+    line = line.split(";")[0]
+    return line
+
+
+class ConfigFile(ConfigDict):
+    """A Git configuration file, like .git/config or ~/.gitconfig.
+    """
+
+    @classmethod
+    def from_file(cls, f):
+        """Read configuration from a file-like object."""
+        ret = cls()
+        section = None
+        setting = None
+        for lineno, line in enumerate(f.readlines()):
+            line = line.lstrip()
+            if setting is None:
+                if _strip_comments(line).strip() == "":
+                    continue
+                if line[0] == "[":
+                    line = _strip_comments(line).rstrip()
+                    if line[-1] != "]":
+                        raise ValueError("expected trailing ]")
+                    key = line.strip()
+                    pts = key[1:-1].split(" ", 1)
+                    pts[0] = pts[0].lower()
+                    if len(pts) == 2:
+                        if pts[1][0] != "\"" or pts[1][-1] != "\"":
+                            raise ValueError(
+                                "Invalid subsection " + pts[1])
+                        else:
+                            pts[1] = pts[1][1:-1]
+                        if not _check_section_name(pts[0]):
+                            raise ValueError("invalid section name %s" %
+                                             pts[0])
+                        section = (pts[0], pts[1])
+                    else:
+                        if not _check_section_name(pts[0]):
+                            raise ValueError("invalid section name %s" %
+                                    pts[0])
+                        pts = pts[0].split(".", 1)
+                        if len(pts) == 2:
+                            section = (pts[0], pts[1])
+                        else:
+                            section = (pts[0], )
+                    ret._values[section] = {}
+                else:
+                    if section is None:
+                        raise ValueError("setting %r without section" % line)
+                    try:
+                        setting, value = line.split("=", 1)
+                    except ValueError:
+                        setting = line
+                        value = "true"
+                    setting = setting.strip().lower()
+                    if not _check_variable_name(setting):
+                        raise ValueError("invalid variable name %s" % setting)
+                    if value.endswith("\\\n"):
+                        value = value[:-2]
+                        continuation = True
+                    else:
+                        continuation = True
+                    value = _parse_string(value)
+                    ret._values[section][setting] = value
+                    if not continuation:
+                        setting = None
+            else: # continuation line
+                if line.endswith("\\\n"):
+                    line = line[:-2]
+                    continuation = True
+                else:
+                    continuation = True
+                value = _parse_string(line)
+                ret._values[section][setting] += value
+                if not continuation:
+                    setting = None
+        return ret
+
+    @classmethod
+    def from_path(cls, path):
+        """Read configuration from a file on disk."""
+        f = GitFile(path, 'rb')
+        try:
+            ret = cls.from_file(f)
+            ret.path = path
+            return ret
+        finally:
+            f.close()
+
+    def write_to_path(self, path=None):
+        """Write configuration to a file on disk."""
+        if path is None:
+            path = self.path
+        f = GitFile(path, 'wb')
+        try:
+            self.write_to_file(f)
+        finally:
+            f.close()
+
+    def write_to_file(self, f):
+        """Write configuration to a file-like object."""
+        for section, values in self._values.iteritems():
+            try:
+                section_name, subsection_name = section
+            except ValueError:
+                (section_name, ) = section
+                subsection_name = None
+            if subsection_name is None:
+                f.write("[%s]\n" % section_name)
+            else:
+                f.write("[%s \"%s\"]\n" % (section_name, subsection_name))
+            for key, value in values.iteritems():
+                f.write("%s = %s\n" % (key, _escape_value(value)))
+
+
+class StackedConfig(Config):
+    """Configuration which reads from multiple config files.."""
+
+    def __init__(self, backends):
+        self._backends = backends
+
+    def __repr__(self):
+        return "<%s for %r>" % (self.__class__.__name__, self._backends)
+
+    @classmethod
+    def default_backends(cls):
+        """Retrieve the default configuration.
+
+        This will look in the repository configuration (if for_path is
+        specified), the users' home directory and the system
+        configuration.
+        """
+        paths = []
+        paths.append(os.path.expanduser("~/.gitconfig"))
+        paths.append("/etc/gitconfig")
+        backends = []
+        for path in paths:
+            try:
+                cf = ConfigFile.from_path(path)
+            except (IOError, OSError), e:
+                if e.errno != errno.ENOENT:
+                    raise
+                else:
+                    continue
+            backends.append(cf)
+        return backends
+
+    def get(self, section, name):
+        for backend in self._backends:
+            try:
+                return backend.get(section, name)
+            except KeyError:
+                pass
+        raise KeyError(name)
+
+    def set(self, section, name, value):
+        raise NotImplementedError(self.set)
+
+

+ 18 - 10
dulwich/repo.py

@@ -800,12 +800,16 @@ class BaseRepo(object):
 
     def _init_files(self, bare):
         """Initialize a default set of named files."""
+        from dulwich.config import ConfigFile
         self._put_named_file('description', "Unnamed repository")
-        self._put_named_file('config', ('[core]\n'
-                                        'repositoryformatversion = 0\n'
-                                        'filemode = true\n'
-                                        'bare = ' + str(bare).lower() + '\n'
-                                        'logallrefupdates = true\n'))
+        f = StringIO()
+        cf = ConfigFile()
+        cf.set("core", "repositoryformatversion", "0")
+        cf.set("core", "filemode", "true")
+        cf.set("core", "bare", str(bare).lower())
+        cf.set("core", "logallrefupdates", "true")
+        cf.write_to_file(f)
+        self._put_named_file('config', f.getvalue())
         self._put_named_file(os.path.join('info', 'exclude'), '')
 
     def get_named_file(self, path):
@@ -917,11 +921,15 @@ class BaseRepo(object):
         return self.commit(sha).parents
 
     def get_config(self):
-        import ConfigParser
-        p = ConfigParser.RawConfigParser()
-        p.read(os.path.join(self._controldir, 'config'))
-        return dict((section, dict(p.items(section)))
-                    for section in p.sections())
+        from dulwich.config import ConfigFile, StackedConfig
+        backends = []
+        try:
+            p = ConfigFile.from_path(os.path.join(self._controldir, 'config'))
+        except (IOError, OSError), e:
+            if e.errno != errno.ENOENT:
+                raise
+        backends.extend(StackedConfig.default_backends())
+        return StackedConfig(backends)
 
     def commit(self, sha):
         """Retrieve the commit with a particular SHA.

+ 1 - 0
dulwich/tests/__init__.py

@@ -75,6 +75,7 @@ def self_test_suite():
     names = [
         'blackbox',
         'client',
+        'config',
         'diff_tree',
         'fastexport',
         'file',

+ 226 - 0
dulwich/tests/test_config.py

@@ -0,0 +1,226 @@
+# test_config.py -- Tests for reading and writing configuration files
+# Copyright (C) 2011 Jelmer Vernooij <jelmer@samba.org>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# or (at your option) a later version of the License.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+# MA  02110-1301, USA.
+
+"""Tests for reading and writing configuraiton files."""
+
+from cStringIO import StringIO
+from dulwich.config import (
+    ConfigDict,
+    ConfigFile,
+    StackedConfig,
+    _check_section_name,
+    _check_variable_name,
+    _format_string,
+    _escape_value,
+    _parse_string,
+    _unescape_value,
+    )
+from dulwich.tests import TestCase
+
+
+class ConfigFileTests(TestCase):
+
+    def from_file(self, text):
+        return ConfigFile.from_file(StringIO(text))
+
+    def test_empty(self):
+        ConfigFile()
+
+    def test_eq(self):
+        self.assertEquals(ConfigFile(), ConfigFile())
+
+    def test_from_file_empty(self):
+        cf = self.from_file("")
+        self.assertEquals(ConfigFile(), cf)
+
+    def test_empty_line_before_section(self):
+        cf = self.from_file("\n[section]\n")
+        self.assertEquals(ConfigFile({("section", ): {}}), cf)
+
+    def test_comment_before_section(self):
+        cf = self.from_file("# foo\n[section]\n")
+        self.assertEquals(ConfigFile({("section", ): {}}), cf)
+
+    def test_comment_after_section(self):
+        cf = self.from_file("[section] # foo\n")
+        self.assertEquals(ConfigFile({("section", ): {}}), cf)
+
+    def test_comment_after_variable(self):
+        cf = self.from_file("[section]\nbar= foo # a comment\n")
+        self.assertEquals(ConfigFile({("section", ): {"bar": "foo"}}), cf)
+
+    def test_from_file_section(self):
+        cf = self.from_file("[core]\nfoo = bar\n")
+        self.assertEquals("bar", cf.get(("core", ), "foo"))
+        self.assertEquals("bar", cf.get(("core", "foo"), "foo"))
+
+    def test_from_file_section_case_insensitive(self):
+        cf = self.from_file("[cOre]\nfOo = bar\n")
+        self.assertEquals("bar", cf.get(("core", ), "foo"))
+        self.assertEquals("bar", cf.get(("core", "foo"), "foo"))
+
+    def test_from_file_with_mixed_quoted(self):
+        cf = self.from_file("[core]\nfoo = \"bar\"la\n")
+        self.assertEquals("barla", cf.get(("core", ), "foo"))
+
+    def test_from_file_with_open_quoted(self):
+        self.assertRaises(ValueError,
+            self.from_file, "[core]\nfoo = \"bar\n")
+
+    def test_from_file_with_quotes(self):
+        cf = self.from_file(
+            "[core]\n"
+            'foo = " bar"\n')
+        self.assertEquals(" bar", cf.get(("core", ), "foo"))
+
+    def test_from_file_with_interrupted_line(self):
+        cf = self.from_file(
+            "[core]\n"
+            'foo = bar\\\n'
+            ' la\n')
+        self.assertEquals("barla", cf.get(("core", ), "foo"))
+
+    def test_from_file_with_boolean_setting(self):
+        cf = self.from_file(
+            "[core]\n"
+            'foo\n')
+        self.assertEquals("true", cf.get(("core", ), "foo"))
+
+    def test_from_file_subsection(self):
+        cf = self.from_file("[branch \"foo\"]\nfoo = bar\n")
+        self.assertEquals("bar", cf.get(("branch", "foo"), "foo"))
+
+    def test_from_file_subsection_invalid(self):
+        self.assertRaises(ValueError,
+            self.from_file, "[branch \"foo]\nfoo = bar\n")
+
+    def test_from_file_subsection_not_quoted(self):
+        cf = self.from_file("[branch.foo]\nfoo = bar\n")
+        self.assertEquals("bar", cf.get(("branch", "foo"), "foo"))
+
+    def test_write_to_file_empty(self):
+        c = ConfigFile()
+        f = StringIO()
+        c.write_to_file(f)
+        self.assertEquals("", f.getvalue())
+
+    def test_write_to_file_section(self):
+        c = ConfigFile()
+        c.set(("core", ), "foo", "bar")
+        f = StringIO()
+        c.write_to_file(f)
+        self.assertEquals("[core]\nfoo = bar\n", f.getvalue())
+
+    def test_write_to_file_subsection(self):
+        c = ConfigFile()
+        c.set(("branch", "blie"), "foo", "bar")
+        f = StringIO()
+        c.write_to_file(f)
+        self.assertEquals("[branch \"blie\"]\nfoo = bar\n", f.getvalue())
+
+
+class ConfigDictTests(TestCase):
+
+    def test_get_set(self):
+        cd = ConfigDict()
+        self.assertRaises(KeyError, cd.get, "foo", "core")
+        cd.set(("core", ), "foo", "bla")
+        self.assertEquals("bla", cd.get(("core", ), "foo"))
+        cd.set(("core", ), "foo", "bloe")
+        self.assertEquals("bloe", cd.get(("core", ), "foo"))
+
+
+class StackedConfigTests(TestCase):
+
+    def test_default_backends(self):
+        StackedConfig.default_backends()
+
+
+class UnescapeTests(TestCase):
+
+    def test_nothing(self):
+        self.assertEquals("", _unescape_value(""))
+
+    def test_tab(self):
+        self.assertEquals("\tbar\t", _unescape_value("\\tbar\\t"))
+
+    def test_newline(self):
+        self.assertEquals("\nbar\t", _unescape_value("\\nbar\\t"))
+
+    def test_quote(self):
+        self.assertEquals("\"foo\"", _unescape_value("\\\"foo\\\""))
+
+
+class EscapeValueTests(TestCase):
+
+    def test_nothing(self):
+        self.assertEquals("foo", _escape_value("foo"))
+
+    def test_backslash(self):
+        self.assertEquals("foo\\\\", _escape_value("foo\\"))
+
+    def test_newline(self):
+        self.assertEquals("foo\\n", _escape_value("foo\n"))
+
+
+class FormatStringTests(TestCase):
+
+    def test_quoted(self):
+        self.assertEquals('" foo"', _format_string(" foo"))
+        self.assertEquals('"\\tfoo"', _format_string("\tfoo"))
+
+    def test_not_quoted(self):
+        self.assertEquals('foo', _format_string("foo"))
+        self.assertEquals('foo bar', _format_string("foo bar"))
+
+
+class ParseStringTests(TestCase):
+
+    def test_quoted(self):
+        self.assertEquals(' foo', _parse_string('" foo"'))
+        self.assertEquals('\tfoo', _parse_string('"\\tfoo"'))
+
+    def test_not_quoted(self):
+        self.assertEquals('foo', _parse_string("foo"))
+        self.assertEquals('foo bar', _parse_string("foo bar"))
+
+
+class CheckVariableNameTests(TestCase):
+
+    def test_invalid(self):
+        self.assertFalse(_check_variable_name("foo "))
+        self.assertFalse(_check_variable_name("bar,bar"))
+        self.assertFalse(_check_variable_name("bar.bar"))
+
+    def test_valid(self):
+        self.assertTrue(_check_variable_name("FOO"))
+        self.assertTrue(_check_variable_name("foo"))
+        self.assertTrue(_check_variable_name("foo-bar"))
+
+
+class CheckSectionNameTests(TestCase):
+
+    def test_invalid(self):
+        self.assertFalse(_check_section_name("foo "))
+        self.assertFalse(_check_section_name("bar,bar"))
+
+    def test_valid(self):
+        self.assertTrue(_check_section_name("FOO"))
+        self.assertTrue(_check_section_name("foo"))
+        self.assertTrue(_check_section_name("foo-bar"))
+        self.assertTrue(_check_section_name("bar.bar"))

+ 4 - 2
dulwich/tests/test_repository.py

@@ -33,6 +33,7 @@ from dulwich.object_store import (
     tree_lookup_path,
     )
 from dulwich import objects
+from dulwich.config import Config
 from dulwich.repo import (
     check_ref_format,
     DictRefsContainer,
@@ -73,7 +74,8 @@ class CreateRepositoryTests(TestCase):
         self.assertFileContentsEqual('', repo, os.path.join('info', 'exclude'))
         self.assertFileContentsEqual(None, repo, 'nonexistent file')
         barestr = 'bare = %s' % str(expect_bare).lower()
-        self.assertTrue(barestr in repo.get_named_file('config').read())
+        config_text = repo.get_named_file('config').read()
+        self.assertTrue(barestr in config_text, "%r" % config_text)
 
     def test_create_disk_bare(self):
         tmp_dir = tempfile.mkdtemp()
@@ -319,7 +321,7 @@ class RepositoryTests(TestCase):
 
     def test_get_config(self):
         r = self._repo = open_repo('ooo_merge.git')
-        self.assertEquals({}, r.get_config())
+        self.assertIsInstance(r.get_config(), Config)
 
     def test_common_revisions(self):
         """