diff --git a/CHANGES.rst b/CHANGES.rst index 1a49e2e..0e0796a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog 0.23 (unreleased) ----------------- +* More reliable svn status parsing; now handles svn externals (`issue #45 + `__). + 0.22 (2014-12-23) ----------------- diff --git a/check_manifest.py b/check_manifest.py index 3121257..52bd6f5 100755 --- a/check_manifest.py +++ b/check_manifest.py @@ -27,6 +27,7 @@ import tempfile import zipfile from contextlib import contextmanager, closing +from xml.etree import cElementTree as ET try: import ConfigParser @@ -126,7 +127,7 @@ def __init__(self, command, status, output): command, status, output)) -def run(command, encoding=None): +def run(command, encoding=None, decode=True): """Run a command [cmd, arg1, arg2, ...]. Returns the output (stdout + stderr). @@ -140,7 +141,9 @@ def run(command, encoding=None): stderr=subprocess.STDOUT) except OSError as e: raise Failure("could not run %s: %s" % (command, e)) - output = pipe.communicate()[0].decode(encoding) + output = pipe.communicate()[0] + if decode: + output = output.decode(encoding) status = pipe.wait() if status != 0: raise CommandFailed(command, status, output) @@ -302,12 +305,55 @@ def get_versioned_files(): class Subversion(VCS): metadata_name = '.svn' - @staticmethod - def get_versioned_files(): + @classmethod + def get_versioned_files(cls): """List all files under SVN control in the current directory.""" - output = run(['svn', 'st', '-vq']) - return sorted(line[12:].split(None, 3)[-1] - for line in output.splitlines()[1:]) + output = run(['svn', 'st', '-vq', '--xml'], decode=False) + tree = ET.XML(output) + return sorted(entry.get('path') for entry in tree.findall('.//entry') + if cls.is_interesting(entry)) + + @staticmethod + def is_interesting(entry): + """Is this entry interesting? + + ``entry`` is an XML node representing one entry of the svn status + XML output. It looks like this:: + + + + + mg + 2015-02-06T07:52:38.163516Z + + + + + + + + + + + + + + + + + """ + if entry.get('path') == '.': + return False + status = entry.find('wc-status') + if status is None: + warning('svn status --xml parse error: without' + ' ' % entry.get('path')) + return False + # For SVN externals we get two entries: one mentioning the + # existence of the external, and one about the status of the external. + if status.get('item') in ('unversioned', 'external'): + return False + return True def detect_vcs(): diff --git a/tests.py b/tests.py index eb499ff..db00859 100644 --- a/tests.py +++ b/tests.py @@ -10,6 +10,7 @@ import zipfile from contextlib import closing from io import BytesIO +from xml.etree import cElementTree as ET try: import unittest2 as unittest # Python 2.6 @@ -914,8 +915,21 @@ def _commit(self): class TestSvn(VCSMixin, unittest.TestCase): vcs = SvnHelper() + def test_svn_externals(self): + from check_manifest import get_vcs_files + self.vcs._run('svnadmin', 'create', 'repo2') + repo2_url = 'file:///' + os.path.abspath('repo2').replace(os.path.sep, '/') + self.vcs._init_vcs() + self.vcs._run('svn', 'propset', 'svn:externals', 'ext %s' % repo2_url, '.') + self.vcs._run('svn', 'up') + self._create_files(['a.txt', 'ext/b.txt']) + self.vcs._run('svn', 'add', 'a.txt', 'ext/b.txt') + j = os.path.join + self.assertEqual(get_vcs_files(), + ['a.txt', 'ext', j('ext', 'b.txt')]) + -class TestUserInterface(unittest.TestCase): +class UIMixin(object): def setUp(self): import check_manifest @@ -931,6 +945,21 @@ def tearDown(self): sys.stdout = self.real_stdout check_manifest.VERBOSE = self.old_VERBOSE + +class TestSvnExtraErrors(UIMixin, unittest.TestCase): + + def test_svn_xml_parsing_warning(self): + from check_manifest import Subversion + entry = ET.XML('') + self.assertFalse(Subversion.is_interesting(entry)) + self.assertEqual( + sys.stderr.getvalue(), + 'svn status --xml parse error: ' + ' without \n') + + +class TestUserInterface(UIMixin, unittest.TestCase): + def test_info(self): import check_manifest check_manifest.VERBOSE = False