summaryrefslogtreecommitdiffstats
path: root/share/extensions/inkex/tester
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-27 16:29:01 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-27 16:29:01 +0000
commit35a96bde514a8897f6f0fcc41c5833bf63df2e2a (patch)
tree657d15a03cc46bd099fc2c6546a7a4ad43815d9f /share/extensions/inkex/tester
parentInitial commit. (diff)
downloadinkscape-a6567f8d928319d85ab4231bf20a5c4aae93fb2b.tar.xz
inkscape-a6567f8d928319d85ab4231bf20a5c4aae93fb2b.zip
Adding upstream version 1.0.2.upstream/1.0.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'share/extensions/inkex/tester')
-rw-r--r--share/extensions/inkex/tester/__init__.py384
-rw-r--r--share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pycbin0 -> 13791 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pycbin0 -> 388 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/filters.cpython-39.pycbin0 -> 6337 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/inx.cpython-39.pycbin0 -> 3340 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/mock.cpython-39.pycbin0 -> 13699 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/svg.cpython-39.pycbin0 -> 1279 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/word.cpython-39.pycbin0 -> 841 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pycbin0 -> 3619 bytes
-rw-r--r--share/extensions/inkex/tester/decorators.py8
-rw-r--r--share/extensions/inkex/tester/filters.py139
-rw-r--r--share/extensions/inkex/tester/inx.py95
-rw-r--r--share/extensions/inkex/tester/mock.py414
-rw-r--r--share/extensions/inkex/tester/svg.py49
-rw-r--r--share/extensions/inkex/tester/word.py38
-rw-r--r--share/extensions/inkex/tester/xmldiff.py113
16 files changed, 1240 insertions, 0 deletions
diff --git a/share/extensions/inkex/tester/__init__.py b/share/extensions/inkex/tester/__init__.py
new file mode 100644
index 0000000..f33f695
--- /dev/null
+++ b/share/extensions/inkex/tester/__init__.py
@@ -0,0 +1,384 @@
+# coding=utf-8
+#
+# Copyright (C) 2018-2019 Martin Owens
+# 2019 Thomas Holder
+#
+# 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 of the License, or
+# (at your option) any 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, USA.
+#
+"""
+All Inkscape extensions should come with tests. This package provides you with
+the tools needed to create tests and thus ensure that your extension continues
+to work with future versions of Inkscape, the "inkex" python modules, and other
+python and non-python tools you may use.
+
+Make sure your extension is a python extension and is using the `inkex.generic`
+base classes. These provide the greatest amount of functionality for testing.
+
+You should start by creating a folder in your repository called `tests` with
+an empty file inside called `__init__.py` to turn it into a module folder.
+
+For each of your extensions, you should create a file called
+`test_{extension_name}.py` where the name reflects the name of your extension.
+
+There are two types of tests:
+
+ 1. Full-process Comparison tests - These are tests which invoke your
+ extension with various arguments and attempt to compare the
+ output to a known good reference. These are useful for testing
+ that your extension would work if it was used in Inkscape.
+
+ Good example of writing comparison tests can be found in the
+ Inkscape core repository, each test which inherits from
+ the ComparisonMixin class is running comparison tests.
+
+ 2. Unit tests - These are individual test functions which call out to
+ specific functions within your extension. These are typical
+ python unit tests and many good python documents exist
+ to describe how to write them well. For examples here you
+ can find the tests that test the inkex modules themselves
+ to be the most instructive.
+
+When running a test, it will cause a certain fraction of the code within the
+extension to execute. This fraction called it's **coverage** and a higher
+coverage score indicates that your test is better at exercising the various
+options, features, and branches within your code.
+
+Generating comparison output can be done using the EXPORT_COMPARE environment
+variable when calling pytest. For example:
+
+ EXPORT_COMPARE=1 pytest tests/test_my_specific_test.py
+
+This will create files in `tests/data/refs/*.out.export` and these files should
+be manually checked to make sure they are correct before being renamed and stripped
+of the `.export` suffix. pytest should then be re-run to confirm before
+committing to the repository.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import os
+import re
+import sys
+import shutil
+import tempfile
+import hashlib
+import random
+import uuid
+
+from io import BytesIO, StringIO
+import xml.etree.ElementTree as xml
+
+from unittest import TestCase as BaseCase
+from inkex.base import InkscapeExtension
+
+from ..utils import PY3, to_bytes
+from .xmldiff import xmldiff
+from .mock import MockCommandMixin, Capture
+
+if False: # pylint: disable=using-constant-test
+ from typing import Type, List
+ from .filters import Compare
+
+
+class NoExtension(InkscapeExtension): # pylint: disable=too-few-public-methods
+ """Test case must specify 'self.effect_class' to assertEffect."""
+
+ def __init__(self, *args, **kwargs): # pylint: disable=super-init-not-called
+ raise NotImplementedError(self.__doc__)
+
+ def run(self, args=None, output=None):
+ """Fake run"""
+ pass
+
+
+class TestCase(MockCommandMixin, BaseCase):
+ """
+ Base class for all effects tests, provides access to data_files and test_without_parameters
+ """
+ effect_class = NoExtension # type: Type[InkscapeExtension]
+ effect_name = property(lambda self: self.effect_class.__module__)
+
+ # If set to true, the output is not expected to be the stdout SVG document, but rather
+ # text or a message sent to the stderr, this is highly weird. But sometimes happens.
+ stderr_output = False
+ stdout_protect = True
+ stderr_protect = True
+ python3_only = False
+
+ def __init__(self, *args, **kw):
+ super(TestCase, self).__init__(*args, **kw)
+ self._temp_dir = None
+ self._effect = None
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Make sure every test is seeded the same way"""
+ self._effect = None
+ super(TestCase, self).setUp()
+ if self.python3_only and not PY3:
+ self.skipTest("No available in python2")
+ try:
+ # python3, with version 1 to get the same numbers
+ # as in python2 during tests.
+ random.seed(0x35f, version=1)
+ except TypeError:
+ # But of course this kwarg doesn't exist in python2
+ random.seed(0x35f)
+
+ def tearDown(self):
+ super(TestCase, self).tearDown()
+ if self._temp_dir and os.path.isdir(self._temp_dir):
+ shutil.rmtree(self._temp_dir)
+
+ @classmethod
+ def __file__(cls):
+ """Create a __file__ property which acts much like the module version"""
+ return os.path.abspath(sys.modules[cls.__module__].__file__)
+
+ @classmethod
+ def _testdir(cls):
+ """Get's the folder where the test exists (so data can be found)"""
+ return os.path.dirname(cls.__file__())
+
+ @classmethod
+ def rootdir(cls):
+ """Return the full path to the extensions directory"""
+ return os.path.dirname(cls._testdir())
+
+ @classmethod
+ def datadir(cls):
+ """Get the data directory (can be over-ridden if needed)"""
+ return os.path.join(cls._testdir(), 'data')
+
+ @property
+ def tempdir(self):
+ """Generate a temporary location to store files"""
+ if self._temp_dir is None:
+ self._temp_dir = tempfile.mkdtemp(prefix='inkex-tests-')
+ if not os.path.isdir(self._temp_dir):
+ raise IOError("The temporary directory has disappeared!")
+ return self._temp_dir
+
+ def temp_file(self, prefix='file-', template='{prefix}{name}{suffix}', suffix='.tmp'):
+ """Generate the filename of a temporary file"""
+ filename = template.format(prefix=prefix, suffix=suffix, name=uuid.uuid4().hex)
+ return os.path.join(self.tempdir, filename)
+
+ @classmethod
+ def data_file(cls, filename, *parts):
+ """Provide a data file from a filename, can accept directories as arguments."""
+ if os.path.isabs(filename):
+ # Absolute root was passed in, so we trust that (it might be a tempdir)
+ full_path = os.path.join(filename, *parts)
+ else:
+ # Otherwise we assume it's relative to the test data dir.
+ full_path = os.path.join(cls.datadir(), filename, *parts)
+
+ if not os.path.isfile(full_path):
+ raise IOError("Can't find test data file: {}".format(full_path))
+ return full_path
+
+ @property
+ def empty_svg(self):
+ """Returns a common minimal svg file"""
+ return self.data_file('svg', 'default-inkscape-SVG.svg')
+
+ def assertAlmostTuple(self, found, expected, precision=8): # pylint: disable=invalid-name
+ """
+ Floating point results may vary with computer architecture; use
+ assertAlmostEqual to allow a tolerance in the result.
+ """
+ self.assertEqual(len(found), len(expected))
+ for fon, exp in zip(found, expected):
+ self.assertAlmostEqual(fon, exp, precision)
+
+ def assertEffectEmpty(self, effect, **kwargs): # pylint: disable=invalid-name
+ """Assert calling effect without any arguments"""
+ self.assertEffect(effect=effect, **kwargs)
+
+ def assertEffect(self, *filename, **kwargs): # pylint: disable=invalid-name
+ """Assert an effect, capturing the output to stdout.
+
+ filename should point to a starting svg document, default is empty_svg
+ """
+ effect = kwargs.pop('effect', self.effect_class)()
+
+ args = [self.data_file(*filename)] if filename else [self.empty_svg] # pylint: disable=no-value-for-parameter
+ args += kwargs.pop('args', [])
+ args += ['--{}={}'.format(*kw) for kw in kwargs.items()]
+
+ # Output is redirected to this string io buffer
+ if self.stderr_output:
+ with Capture('stderr') as stderr:
+ effect.run(args, output=BytesIO())
+ effect.test_output = stderr
+ else:
+ output = BytesIO()
+ with Capture('stdout', kwargs.get('stdout_protect', self.stdout_protect)) as stdout:
+ with Capture('stderr', kwargs.get('stderr_protect', self.stderr_protect)) as stderr:
+ effect.run(args, output=output)
+ self.assertEqual('', stdout.getvalue(), "Extra print statements detected")
+ self.assertEqual('', stderr.getvalue(), "Extra error or warnings detected")
+ effect.test_output = output
+
+ if os.environ.get('FAIL_ON_DEPRECATION', False):
+ warnings = getattr(effect, 'warned_about', set())
+ effect.warned_about = set() # reset for next test
+ self.assertFalse(warnings, "Deprecated API is still being used!")
+
+ return effect
+
+ def assertDeepAlmostEqual(self, first, second, places=None, msg=None, delta=None):
+ if delta is None and places is None:
+ places = 7
+ if isinstance(first, (list, tuple)):
+ assert len(first) == len(second)
+ for (f, s) in zip(first, second):
+ self.assertDeepAlmostEqual(f, s, places, msg, delta)
+ else:
+ self.assertAlmostEqual(first, second, places, msg, delta)
+
+ @property
+ def effect(self):
+ """Generate an effect object"""
+ if self._effect is None:
+ self._effect = self.effect_class()
+ return self._effect
+
+class InkscapeExtensionTestMixin(object):
+ """Automatically setup self.effect for each test and test with an empty svg"""
+ def setUp(self): # pylint: disable=invalid-name
+ """Check if there is an effect_class set and create self.effect if it is"""
+ super(InkscapeExtensionTestMixin, self).setUp()
+ if self.effect_class is None:
+ self.skipTest('self.effect_class is not defined for this this test')
+
+ def test_default_settings(self):
+ """Extension works with empty svg file"""
+ self.effect.run([self.empty_svg])
+
+class ComparisonMixin(object):
+ """
+ Add comparison tests to any existing test suite.
+ """
+ # This input svg file sent to the extension (if any)
+ compare_file = 'svg/shapes.svg'
+ # The ways in which the output is filtered for comparision (see filters.py)
+ compare_filters = [] # type: List[Compare]
+ # If true, the filtered output will be saved and only applied to the
+ # extension output (and not to the reference file)
+ compare_filter_save = False
+ # A list of comparison runs, each entry will cause the extension to be run.
+ comparisons = [
+ (),
+ ('--id=p1', '--id=r3'),
+ ]
+
+ def test_all_comparisons(self):
+ """Testing all comparisons"""
+ if not isinstance(self.compare_file, (list, tuple)):
+ self._test_comparisons(self.compare_file)
+ else:
+ for compare_file in self.compare_file:
+ self._test_comparisons(
+ compare_file,
+ addout=os.path.basename(compare_file)
+ )
+
+ def _test_comparisons(self, compare_file, addout=None):
+ for args in self.comparisons:
+ self.assertCompare(
+ compare_file,
+ self.get_compare_outfile(args, addout),
+ args,
+ )
+
+ def assertCompare(self, infile, outfile, args): #pylint: disable=invalid-name
+ """
+ Compare the output of a previous run against this one.
+
+ - infile: The filename of the pre-processed svg (or other type of file)
+ - outfile: The filename of the data we expect to get, if not set
+ the filename will be generated from the effect name and kwargs.
+ - args: All the arguments to be passed to the effect run
+
+ """
+ effect = self.assertEffect(infile, args=args)
+
+ if outfile is None:
+ outfile = self.get_compare_outfile(args)
+
+ if not os.path.isfile(outfile):
+ raise IOError("Comparison file {} not found".format(outfile))
+
+ data_a = effect.test_output.getvalue()
+ if os.environ.get('EXPORT_COMPARE', False):
+ with open(outfile + '.export', 'wb') as fhl:
+ if sys.version_info[0] == 3 and isinstance(data_a, str):
+ data_a = data_a.encode('utf-8')
+ fhl.write(self._apply_compare_filters(data_a, True))
+ print("Written output: {}.export".format(outfile))
+
+ data_a = self._apply_compare_filters(data_a)
+
+ with open(outfile, 'rb') as fhl:
+ data_b = self._apply_compare_filters(fhl.read(), False)
+
+ if isinstance(data_a, bytes) and isinstance(data_b, bytes) \
+ and data_a.startswith(b'<') and data_b.startswith(b'<'):
+ # Late importing
+ diff_xml, delta = xmldiff(data_a, data_b)
+ if not delta and not os.environ.get('EXPORT_COMPARE', False):
+ print('The XML is different, you can save the output using the EXPORT_COMPARE=1'\
+ ' envionment variable. This will save the compared file as a ".output" file'\
+ ' next to the reference file used in the test.\n')
+ diff = 'SVG Differences: {}\n\n'.format(outfile)
+ if os.environ.get('XML_DIFF', False):
+ diff = '<- ' + diff_xml
+ else:
+ for x, (value_a, value_b) in enumerate(delta):
+ try:
+ # Take advantage of better text diff in testcase's own asserts.
+ self.assertEqual(value_a, value_b)
+ except AssertionError as err:
+ diff += " {}. {}\n".format(x, str(err))
+ self.assertTrue(delta, diff)
+ else:
+ # compare any content (non svg)
+ self.assertEqual(data_a, data_b)
+
+ def _apply_compare_filters(self, data, is_saving=None):
+ data = to_bytes(data)
+ # Applying filters flips depending if we are saving the filtered content
+ # to disk, or filtering during the test run. This is because some filters
+ # are destructive others are useful for diagnostics.
+ if is_saving is self.compare_filter_save or is_saving is None:
+ for cfilter in self.compare_filters:
+ data = cfilter(data)
+ return data
+
+ def get_compare_outfile(self, args, addout=None):
+ """Generate an output file for the arguments given"""
+ if addout is not None:
+ args = list(args) + [str(addout)]
+ opstr = '__'.join(args)\
+ .replace(self.tempdir, 'TMP_DIR')\
+ .replace(self.datadir(), 'DAT_DIR')
+ opstr = re.sub(r'[^\w-]', '__', opstr)
+ if opstr:
+ if len(opstr) > 127:
+ # avoid filename-too-long error
+ opstr = hashlib.md5(opstr.encode('latin1')).hexdigest()
+ opstr = '__' + opstr
+ return self.data_file("refs", "{}{}.out".format(self.effect_name, opstr))
diff --git a/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc
new file mode 100644
index 0000000..7d6722e
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc
new file mode 100644
index 0000000..6c98af5
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc
new file mode 100644
index 0000000..b460938
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc
new file mode 100644
index 0000000..7f8204a
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc
new file mode 100644
index 0000000..82c6234
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc
new file mode 100644
index 0000000..439198f
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc
new file mode 100644
index 0000000..cd16936
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc
new file mode 100644
index 0000000..5e61017
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/decorators.py b/share/extensions/inkex/tester/decorators.py
new file mode 100644
index 0000000..92d6b4d
--- /dev/null
+++ b/share/extensions/inkex/tester/decorators.py
@@ -0,0 +1,8 @@
+"""
+Useful decorators for tests.
+"""
+import pytest
+from inkex.command import is_inkscape_available
+
+requires_inkscape = pytest.mark.skipif( # pylint: disable=invalid-name
+ not is_inkscape_available(), reason="Test requires inkscape, but it's not available")
diff --git a/share/extensions/inkex/tester/filters.py b/share/extensions/inkex/tester/filters.py
new file mode 100644
index 0000000..f23d124
--- /dev/null
+++ b/share/extensions/inkex/tester/filters.py
@@ -0,0 +1,139 @@
+#
+# Copyright (C) 2019 Thomas Holder
+#
+# 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 of the License, or
+# (at your option) any 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.
+#
+# pylint: disable=too-few-public-methods
+#
+"""
+Comparison filters for use with the ComparisonMixin.
+
+Each filter should be initialised in the list of
+filters that are being used.
+
+compare_filters = [
+ CompareNumericFuzzy(),
+ CompareOrderIndependentLines(option=yes),
+]
+"""
+
+import re
+from ..utils import to_bytes
+
+class Compare(object):
+ """
+ Comparison base class, this acts as a passthrough unless
+ the filter staticmethod is overwritten.
+ """
+ def __init__(self, **options):
+ self.options = options
+
+ def __call__(self, content):
+ return self.filter(content)
+
+ @staticmethod
+ def filter(contents):
+ """Replace this filter method with your own filtering"""
+ return contents
+
+class CompareNumericFuzzy(Compare):
+ """
+ Turn all numbers into shorter standard formats
+
+ 1.2345678 -> 1.2346
+ 1.2300 -> 1.23, 50.0000 -> 50.0
+ 50.0 -> 50
+ """
+ @staticmethod
+ def filter(contents):
+ func = lambda m: b'%.3f' % (float(m.group(0)))
+ contents = re.sub(br'\d+\.\d+', func, contents)
+ contents = re.sub(br'(\d\.\d+?)0+\b', br'\1', contents)
+ contents = re.sub(br'(\d)\.0+(?=\D|\b)', br'\1', contents)
+ return contents
+
+class CompareWithoutIds(Compare):
+ """Remove all ids from the svg"""
+ @staticmethod
+ def filter(contents):
+ return re.sub(br' id="([^"]*)"', b'', contents)
+
+class CompareWithPathSpace(Compare):
+ """Make sure that path segment commands have spaces around them"""
+ @staticmethod
+ def filter(contents):
+ def func(match):
+ """We've found a path command, process it"""
+ new = re.sub(br'\s*([LZMHVCSQTAatqscvhmzl])\s*', br' \1 ', match.group(1))
+ return b' d="' + new.replace(b',', b' ') + b'"'
+ return re.sub(br' d="([^"]*)"', func, contents)
+
+class CompareSize(Compare):
+ """Compare the length of the contents instead of the contents"""
+ @staticmethod
+ def filter(contents):
+ return len(contents)
+
+class CompareOrderIndependentBytes(Compare):
+ """Take all the bytes and sort them"""
+ @staticmethod
+ def filter(contents):
+ return b"".join([bytes(i) for i in sorted(contents)])
+
+class CompareOrderIndependentLines(Compare):
+ """Take all the lines and sort them"""
+ @staticmethod
+ def filter(contents):
+ return b"\n".join(sorted(contents.splitlines()))
+
+class CompareOrderIndependentStyle(Compare):
+ """Take all styles and sort the results"""
+ @staticmethod
+ def filter(contents):
+ contents = CompareNumericFuzzy.filter(contents)
+ def func(match):
+ """Search and replace function for sorting"""
+ sty = b';'.join(sorted(match.group(1).split(b';')))
+ return b'style="%s"' % (sty,)
+ return re.sub(br'style="([^"]*)"', func, contents)
+
+class CompareOrderIndependentStyleAndPath(Compare):
+ """Take all styles and paths and sort them both"""
+ @staticmethod
+ def filter(contents):
+ contents = CompareOrderIndependentStyle.filter(contents)
+ def func(match):
+ """Search and replace function for sorting"""
+ path = b'X'.join(sorted(re.split(br'[A-Z]', match.group(1))))
+ return b'd="%s"' % (path,)
+ return re.sub(br'\bd="([^"]*)"', func, contents)
+
+class CompareOrderIndependentTags(Compare):
+ """Sorts all the XML tags"""
+ @staticmethod
+ def filter(contents):
+ return b"\n".join(sorted(re.split(br'>\s*<', contents)))
+
+class CompareReplacement(Compare):
+ """Replace pieces to make output more comparable"""
+ def __init__(self, *replacements):
+ self.deltas = replacements
+ super(CompareReplacement, self).__init__()
+
+ def filter(self, contents):
+ contents = to_bytes(contents)
+ for _from, _to in self.deltas:
+ contents = contents.replace(to_bytes(_from), to_bytes(_to))
+ return contents
diff --git a/share/extensions/inkex/tester/inx.py b/share/extensions/inkex/tester/inx.py
new file mode 100644
index 0000000..a30eb7e
--- /dev/null
+++ b/share/extensions/inkex/tester/inx.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python
+# coding=utf-8
+"""
+Test elements extra logic from svg xml lxml custom classes.
+"""
+
+from ..utils import PY3
+from ..inx import InxFile
+
+INTERNAL_ARGS = ('help', 'output', 'id', 'selected-nodes')
+ARG_TYPES = {
+ 'Boolean': 'bool',
+ 'Color': 'color',
+ 'str': 'string',
+ 'int': 'int',
+ 'float': 'float',
+}
+
+class InxMixin(object):
+ """Tools for Testing INX files, use as a mixin class:
+
+ class MyTests(InxMixin, TestCase):
+ def test_inx_file(self):
+ self.assertInxIsGood("some_inx_file.inx")
+ """
+ def assertInxIsGood(self, inx_file): # pylint: disable=invalid-name
+ """Test the inx file for consistancy and correctness"""
+ self.assertTrue(PY3, "INX files can only be tested in python3")
+
+ inx = InxFile(inx_file)
+ if 'help' in inx.ident or inx.script.get('interpreter', None) != 'python':
+ return
+ cls = inx.extension_class
+ # Check class can be matched in python file
+ self.assertTrue(cls, 'Can not find class for {}'.format(inx.filename))
+ # Check name is reasonable for the class
+ if not cls.multi_inx:
+ self.assertEqual(
+ cls.__name__, inx.slug,
+ "Name of extension class {}.{} is different from ident {}".format(
+ cls.__module__, cls.__name__, inx.slug))
+ self.assertParams(inx, cls)
+
+ def assertParams(self, inx, cls): # pylint: disable=invalid-name
+ """Confirm the params in the inx match the python script"""
+ params = dict([(param.name, self.parse_param(param)) for param in inx.params])
+ args = dict(self.introspect_arg_parser(cls().arg_parser))
+ mismatch_a = list(set(params) ^ set(args) & set(params))
+ mismatch_b = list(set(args) ^ set(params) & set(args))
+ self.assertFalse(mismatch_a, "{}: Inx params missing from arg parser".format(inx.filename))
+ self.assertFalse(mismatch_b, "{}: Script args missing from inx xml".format(inx.filename))
+
+ for param in args:
+ if params[param]['type'] and args[param]['type']:
+ self.assertEqual(
+ params[param]['type'],
+ args[param]['type'],
+ "Type is not the same for {}:param:{}".format(inx.filename, param))
+
+ def introspect_arg_parser(self, arg_parser):
+ """Pull apart the arg parser to find out what we have in it"""
+ for action in arg_parser._optionals._actions: # pylint: disable=protected-access
+ for opt in action.option_strings:
+ # Ignore params internal to inkscape (thus not in the inx)
+ if opt.startswith('--') and opt[2:] not in INTERNAL_ARGS:
+ yield (opt[2:], self.introspect_action(action))
+
+ @staticmethod
+ def introspect_action(action):
+ """Pull apart a single action to get at the juicy insides"""
+ return {
+ 'type': ARG_TYPES.get((action.type or str).__name__, 'string'),
+ 'default': action.default,
+ 'choices': action.choices,
+ 'help': action.help,
+ }
+
+ @staticmethod
+ def parse_param(param):
+ """Pull apart the param element in the inx file"""
+ if param.param_type in ('optiongroup', 'notebook'):
+ options = param.options
+ return {
+ 'type': None,
+ 'choices': options,
+ 'default': options and options[0] or None,
+ }
+ param_type = param.param_type
+ if param.param_type in ('path',):
+ param_type = 'string'
+ return {
+ 'type': param_type,
+ 'default': param.text,
+ 'choices': None,
+ }
diff --git a/share/extensions/inkex/tester/mock.py b/share/extensions/inkex/tester/mock.py
new file mode 100644
index 0000000..2df8791
--- /dev/null
+++ b/share/extensions/inkex/tester/mock.py
@@ -0,0 +1,414 @@
+# coding=utf-8
+#
+# Copyright (C) 2018 Martin Owens
+#
+# 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 of the License, or
+# (at your option) any 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, USA.
+#
+# pylint: disable=protected-access,too-few-public-methods
+"""
+Any mocking utilities required by testing. Mocking is when you need the test
+to exercise a piece of code, but that code may or does call on something
+outside of the target code that either takes too long to run, isn't available
+during the test running process or simply shouldn't be running at all.
+"""
+
+import io
+import os
+import sys
+import logging
+import hashlib
+import tempfile
+
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.parser import Parser as EmailParser
+
+import inkex.command
+
+if False: # pylint: disable=using-constant-test
+ from typing import List, Tuple, Callable, Any # pylint: disable=unused-import
+
+FIXED_BOUNDARY = '--CALLDATA--//--CALLDATA--'
+
+class Capture(object):
+ """Capture stdout or stderr. Used as `with Capture('stdout') as stream:`"""
+ def __init__(self, io_name='stdout', swap=True):
+ self.io_name = io_name
+ self.original = getattr(sys, io_name)
+ self.stream = io.StringIO()
+ self.swap = swap
+
+ def __enter__(self):
+ # We can't control python2 correctly (unicode vs. bytes-like) but
+ # we don't need it, so we're ignore python2 as if it doesn't exist.
+ if self.swap:
+ setattr(sys, self.io_name, self.stream)
+ return self.stream
+
+ def __exit__(self, exc, value, traceback):
+ if exc is not None and self.swap:
+ # Dump content back to original if there was an error.
+ self.original.write(self.stream.getvalue())
+ setattr(sys, self.io_name, self.original)
+
+class ManualVerbosity(object):
+ """Change the verbosity of the test suite manually"""
+ result = property(lambda self: self.test._current_result)
+
+ def __init__(self, test, okay=True, dots=False):
+ self.test = test
+ self.okay = okay
+ self.dots = dots
+
+ def flip(self, exc_type=None, exc_val=None, exc_tb=None): # pylint: disable=unused-argument
+ """Swap the stored verbosity with the original"""
+ self.okay, self.result.showAll = self.result.showAll, self.okay
+ self.dots, self.result.dots = self.result.dots, self.okay
+
+ __enter__ = flip
+ __exit__ = flip
+
+
+class MockMixin(object):
+ """
+ Add mocking ability to any test base class, will set up mock on setUp
+ and remove it on tearDown.
+
+ Mocks are stored in an array attached to the test class (not instance!) which
+ ensures that mocks can only ever be setUp once and can never be reset over
+ themselves. (just in case this looks weird at first glance)
+
+ class SomeTest(MockingMixin, TestBase):
+ mocks = [(sys, 'exit', NoSystemExit("Nope!")]
+ """
+ mocks = [] # type: List[Tuple[Any, str, Any]]
+
+ def setUpMock(self, owner, name, new): # pylint: disable=invalid-name
+ """Setup the mock here, taking name and function and returning (name, old)"""
+ old = getattr(owner, name)
+ if isinstance(new, str):
+ if hasattr(self, new):
+ new = getattr(self, new)
+ if isinstance(new, Exception):
+ def _error_function(*args2, **kw2): # pylint: disable=unused-argument
+ raise type(new)(str(new))
+ setattr(owner, name, _error_function)
+ elif new is None or isinstance(new, (str, int, float, list, tuple)):
+ def _value_function(*args, **kw): # pylint: disable=unused-argument
+ return new
+ setattr(owner, name, _value_function)
+ else:
+ setattr(owner, name, new)
+ # When we start, mocks contains length 3 tuples, when we're finished, it contains
+ # length 4, this stops remocking and reunmocking from taking place.
+ return (owner, name, old, False)
+
+ def setUp(self): # pylint: disable=invalid-name
+ """For each mock instruction, set it up and store the return"""
+ super(MockMixin, self).setUp()
+ for x, mock in enumerate(self.mocks):
+ if len(mock) == 4:
+ logging.error("Mock was already set up, so it wasn't cleared previously!")
+ continue
+ self.mocks[x] = self.setUpMock(*mock)
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """For each returned stored, tear it down and restore mock instruction"""
+ super(MockMixin, self).tearDown()
+ try:
+ for x, (owner, name, old, _) in enumerate(self.mocks):
+ self.mocks[x] = (owner, name, getattr(owner, name))
+ setattr(owner, name, old)
+ except ValueError:
+ logging.warning("Was never mocked, did something go wrong?")
+
+ def old_call(self, name):
+ """Get the original caller"""
+ for arg in self.mocks:
+ if arg[1] == name:
+ return arg[2]
+ return lambda: None
+
+class MockCommandMixin(MockMixin):
+ """
+ Replace all the command functions with testable replacements.
+
+ This stops the pipeline and people without the programs, running into problems.
+ """
+ mocks = [
+ (inkex.command, '_call', 'mock_call'),
+ (tempfile, 'mkdtemp', 'record_tempdir'),
+ ]
+ recorded_tempdirs = [] # type:List[str]
+
+ def setUp(self): # pylint: disable=invalid-name
+ super(MockCommandMixin, self).setUp()
+ # This is a the daftest thing I've ever seen, when in the middle
+ # of a mock, the 'self' variable magically turns from a FooTest
+ # into a TestCase, this makes it impossible to find the datadir.
+ from . import TestCase
+ TestCase._mockdatadir = self.datadir()
+
+ @classmethod
+ def cmddir(cls):
+ """Returns the location of all the mocked command results"""
+ from . import TestCase
+ return os.path.join(TestCase._mockdatadir, 'cmd')
+
+ def record_tempdir(self, *args, **kwargs):
+ """Record any attempts to make tempdirs"""
+ newdir = self.old_call('mkdtemp')(*args, **kwargs)
+ self.recorded_tempdirs.append(newdir)
+ return newdir
+
+ def clean_paths(self, data, files):
+ """Clean a string of any files or tempdirs"""
+ try:
+ for fdir in self.recorded_tempdirs:
+ data = data.replace(fdir, '.')
+ files = [fname.replace(fdir, '.') for fname in files]
+ for fname in files:
+ data = data.replace(fname, os.path.basename(fname))
+ except (UnicodeDecodeError, TypeError):
+ pass
+ return data
+
+ def get_all_tempfiles(self):
+ """Returns a set() of all files currently in any of the tempdirs"""
+ ret = set([])
+ for fdir in self.recorded_tempdirs:
+ if not os.path.isdir(fdir):
+ continue
+ for fname in os.listdir(fdir):
+ if fname in ('.', '..'):
+ continue
+ path = os.path.join(fdir, fname)
+ # We store the modified time so if a program modifies
+ # the input file in-place, it will look different.
+ ret.add(path + ';{}'.format(os.path.getmtime(path)))
+
+ return ret
+
+ def ignore_command_mock(self, program, arglst):
+ """Return true if the mock is ignored"""
+ if self and program and arglst:
+ return os.environ.get('NO_MOCK_COMMANDS')
+ return False
+
+ def mock_call(self, program, *args, **kwargs):
+ """
+ Replacement for the inkex.command.call() function, instead of calling
+ an external program, will compile all arguments into a hash and use the
+ hash to find a command result.
+ """
+ # Remove stdin first because it needs to NOT be in the Arguments list.
+ stdin = kwargs.pop('stdin', None)
+ args = list(args)
+
+ # We use email
+ msg = MIMEMultipart(boundary=FIXED_BOUNDARY)
+ msg['Program'] = self.get_program_name(program)
+
+ # Gather any output files and add any input files to msg, args and kwargs
+ # may be modified to strip out filename directories (which change)
+ inputs, outputs = self.add_call_files(msg, args, kwargs)
+
+ arglst = inkex.command.to_args(program, *args, **kwargs)[1:]
+ arglst.sort()
+ argstr = ' '.join(arglst)
+ argstr = self.clean_paths(argstr, inputs + outputs)
+ msg['Arguments'] = argstr.strip()
+
+ if stdin is not None:
+ # The stdin is counted as the msg body
+ cleanin = self.clean_paths(stdin, inputs + outputs)
+ msg.attach(MIMEText(cleanin, 'plain', 'utf-8'))
+
+ keystr = msg.as_string()
+ # There is a difference between python2 and python3 output
+ keystr = keystr.replace('\n\n', '\n')
+ keystr = keystr.replace('\n ', ' ')
+ if 'verb' in keystr:
+ # Verbs seperated by colons cause diff in py2/3
+ keystr = keystr.replace('; ', ';')
+ # Generate a unique key for this call based on _all_ it's inputs
+ key = hashlib.md5(keystr.encode('utf-8')).hexdigest()
+
+ if self.ignore_command_mock(program, arglst):
+ # Call original code. This is so programmers can run the test suite
+ # against the external programs too, to see how their fair.
+ if stdin is not None:
+ kwargs['stdin'] = stdin
+
+ before = self.get_all_tempfiles()
+ stdout = self.old_call('_call')(program, *args, **kwargs)
+ outputs += list(self.get_all_tempfiles() - before)
+ # Remove the modified time from the call
+ outputs = [out.rsplit(';', 1)[0] for out in outputs]
+
+ # After the program has run, we collect any file outputs and store
+ # them, then store any stdout or stderr created during the run.
+ # A developer can then use this to build new test cases.
+ reply = MIMEMultipart(boundary=FIXED_BOUNDARY)
+ reply['Program'] = self.get_program_name(program)
+ reply['Arguments'] = argstr
+ self.save_call(program, key, stdout, outputs, reply)
+ self.save_key(program, key, keystr, 'key')
+ return stdout
+
+ try:
+ return self.load_call(program, key, outputs)
+ except IOError:
+ self.save_key(program, key, keystr, 'bad-key')
+ raise IOError("Problem loading call: {}/{} use the environment variable "\
+ "NO_MOCK_COMMANDS=1 to call out to the external program and generate "\
+ "the mock call file.".format(program, key))
+
+ def add_call_files(self, msg, args, kwargs):
+ """
+ Gather all files, adding input files to the msg (for hashing) and
+ output files to the returned files list (for outputting in debug)
+ """
+ # Gather all possible string arguments together.
+ loargs = sorted(kwargs.items(), key=lambda i: i[0])
+ values = []
+ for arg in args:
+ if isinstance(arg, (tuple, list)):
+ loargs.append(arg)
+ else:
+ values.append(str(arg))
+
+ for (_, value) in loargs:
+ if isinstance(value, (tuple, list)):
+ for val in value:
+ if val is not True:
+ values.append(str(val))
+ elif value is not True:
+ values.append(str(value))
+
+ # See if any of the strings could be filenames, either going to be
+ # or are existing files on the disk.
+ files = [[], []]
+ for value in values:
+ if os.path.isfile(value): # Input file
+ files[0].append(value)
+ self.add_call_file(msg, value)
+ elif os.path.isdir(os.path.dirname(value)): # Output file
+ files[1].append(value)
+ return files
+
+ def add_call_file(self, msg, filename):
+ """Add a single file to the given mime message"""
+ fname = os.path.basename(filename)
+ with open(filename, "rb") as fhl:
+ if filename.endswith('.svg'):
+ value = self.clean_paths(fhl.read().decode('utf8'), [])
+ else:
+ value = fhl.read()
+ part = MIMEApplication(value, Name=fname)
+ # After the file is closed
+ part['Content-Disposition'] = 'attachment'
+ part['Filename'] = fname
+ msg.attach(part)
+
+ def get_call_filename(self, program, key, create=False):
+ """
+ Get the filename for the call testing information.
+ """
+ path = self.get_call_path(program, create=create)
+ fname = os.path.join(path, key + '.msg')
+ if not create and not os.path.isfile(fname):
+ raise IOError("Attempted to find call test data {}".format(key))
+ return fname
+
+ def get_program_name(self, program):
+ """Takes a program and returns a program name"""
+ if program == inkex.command.INKSCAPE_EXECUTABLE_NAME:
+ return 'inkscape'
+ return program
+
+ def get_call_path(self, program, create=True):
+ """Get where this program would store it's test data"""
+ command_dir = os.path.join(self.cmddir(), self.get_program_name(program))
+ if not os.path.isdir(command_dir):
+ if create:
+ os.makedirs(command_dir)
+ else:
+ raise IOError("A test is attempting to use an external program in a test:"\
+ " {}; but there is not a command data directory which should"\
+ " contain the results of the command here: {}"\
+ .format(program, command_dir))
+ return command_dir
+
+ def load_call(self, program, key, files):
+ """
+ Load the given call
+ """
+ fname = self.get_call_filename(program, key, create=False)
+ with open(fname, 'rb') as fhl:
+ msg = EmailParser().parsestr(fhl.read().decode('utf-8'))
+
+ stdout = None
+ for part in msg.walk():
+ if 'attachment' in part.get("Content-Disposition", ''):
+ base_name = part['Filename']
+ for out_file in files:
+ if out_file.endswith(base_name):
+ with open(out_file, 'wb') as fhl:
+ fhl.write(part.get_payload(decode=True))
+ part = None
+ if part is not None:
+ # Was not caught by any normal outputs, so we will
+ # save the file to EVERY tempdir in the hopes of
+ # hitting on of them.
+ for fdir in self.recorded_tempdirs:
+ if os.path.isdir(fdir):
+ with open(os.path.join(fdir, base_name), 'wb') as fhl:
+ fhl.write(part.get_payload(decode=True))
+ elif part.get_content_type() == "text/plain":
+ stdout = part.get_payload(decode=True)
+
+ return stdout
+
+ def save_call(self, program, key, stdout, files, msg, ext='output'): # pylint: disable=too-many-arguments
+ """
+ Saves the results from the call into a debug output file, the resulting files
+ should be a Mime msg file format with each attachment being one of the input
+ files as well as any stdin and arguments used in the call.
+ """
+ if stdout is not None and stdout.strip():
+ # The stdout is counted as the msg body here
+ msg.attach(MIMEText(stdout.decode('utf-8'), 'plain', 'utf-8'))
+
+ for fname in set(files):
+ if os.path.isfile(fname):
+ #print("SAVING FILE INTO MSG: {}".format(fname))
+ self.add_call_file(msg, fname)
+ else:
+ part = MIMEText("Missing File", 'plain', 'utf-8')
+ part.add_header('Filename', os.path.basename(fname))
+ msg.attach(part)
+
+ fname = self.get_call_filename(program, key, create=True) + '.' + ext
+ with open(fname, 'wb') as fhl:
+ fhl.write(msg.as_string().encode('utf-8'))
+
+ def save_key(self, program, key, keystr, ext='key'):
+ """Save the key file if we are debugging the key data"""
+ if os.environ.get('DEBUG_KEY'):
+ fname = self.get_call_filename(program, key, create=True) + '.' + ext
+ with open(fname, 'wb') as fhl:
+ fhl.write(keystr.encode('utf-8'))
diff --git a/share/extensions/inkex/tester/svg.py b/share/extensions/inkex/tester/svg.py
new file mode 100644
index 0000000..d7ef569
--- /dev/null
+++ b/share/extensions/inkex/tester/svg.py
@@ -0,0 +1,49 @@
+# coding=utf-8
+#
+# Copyright (C) 2018 Martin Owens
+#
+# 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 of the License, or
+# (at your option) any 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, USA.
+#
+"""
+SVG specific utilities for tests.
+"""
+
+from lxml import etree
+
+from inkex import SVG_PARSER
+
+def svg(svg_attrs=''):
+ """Returns xml etree based on a simple SVG element.
+
+ svg_attrs: A string containing attributes to add to the
+ root <svg> element of a minimal SVG document.
+ """
+ return etree.fromstring(str.encode(
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'
+ '<svg {}></svg>'.format(svg_attrs)), parser=SVG_PARSER)
+
+
+def uu_svg(user_unit):
+ """Same as svg, but takes a user unit for the new document.
+
+ It's based on the ratio between the SVG width and the viewBox width.
+ """
+ return svg('width="1{}" viewBox="0 0 1 1"'.format(user_unit))
+
+def svg_file(filename):
+ """Parse an svg file and return it's document root"""
+ with open(filename, 'r') as fhl:
+ doc = etree.parse(fhl, parser=SVG_PARSER)
+ return doc.getroot()
diff --git a/share/extensions/inkex/tester/word.py b/share/extensions/inkex/tester/word.py
new file mode 100644
index 0000000..cab7d04
--- /dev/null
+++ b/share/extensions/inkex/tester/word.py
@@ -0,0 +1,38 @@
+# coding=utf-8
+#
+# Unknown author
+#
+"""
+Generate words for testing.
+"""
+
+import string
+import random
+
+def word_generator(text_length):
+ """
+ Generate a word of text_length size
+ """
+ word = ""
+
+ for _ in range(0, text_length):
+ word += random.choice(string.ascii_lowercase + \
+ string.ascii_uppercase + \
+ string.digits + \
+ string.punctuation)
+
+ return word
+
+def sentencecase(word):
+ """Make a word standace case"""
+ word_new = ""
+ lower_letters = list(string.ascii_lowercase)
+ first = True
+ for letter in word:
+ if letter in lower_letters and first is True:
+ word_new += letter.upper()
+ first = False
+ else:
+ word_new += letter
+
+ return word_new
diff --git a/share/extensions/inkex/tester/xmldiff.py b/share/extensions/inkex/tester/xmldiff.py
new file mode 100644
index 0000000..17f43a4
--- /dev/null
+++ b/share/extensions/inkex/tester/xmldiff.py
@@ -0,0 +1,113 @@
+#
+# Copyright 2011 (c) Ian Bicking <ianb@colorstudy.com>
+# 2019 (c) Martin Owens <doctormo@gmail.com>
+#
+# Taken from http://formencode.org under the GPL compatible PSF License.
+# Modified to produce more output as a diff.
+#
+"""
+Allow two xml files/lxml etrees to be compared, returning their differences.
+"""
+import xml.etree.ElementTree as xml
+from io import BytesIO
+
+def text_compare(test1, test2):
+ """
+ Compare two text strings while allowing for '*' to match
+ anything on either lhs or rhs.
+ """
+ if not test1 and not test2:
+ return True
+ if test1 == '*' or test2 == '*':
+ return True
+ return (test1 or '').strip() == (test2 or '').strip()
+
+class DeltaLogger(list):
+ """A record keeper of the delta between two svg files"""
+ def append_tag(self, tag_a, tag_b):
+ """Record a tag difference"""
+ if tag_a:
+ tag_a = "<{}.../>".format(tag_a)
+ if tag_b:
+ tag_b = "<{}.../>".format(tag_b)
+ self.append((tag_a, tag_b))
+
+ def append_attr(self, attr, value_a, value_b):
+ """Record an attribute difference"""
+ def _prep(val):
+ if val:
+ if attr == 'd':
+ from inkex.paths import Path
+ return [attr] + Path(val).to_arrays()
+ return (attr, val)
+ return val
+ self.append((_prep(value_a), _prep(value_b)))
+
+ def append_text(self, text_a, text_b):
+ """Record a text difference"""
+ self.append((text_a, text_b))
+
+ def __bool__(self):
+ """Returns True if there's no log, i.e. the delta is clean"""
+ return not self.__len__()
+ __nonzero__ = __bool__
+
+ def __repr__(self):
+ if self:
+ return "No differences detected"
+ return "{} xml differences".format(len(self))
+
+def to_xml(data):
+ """Convert string or bytes to xml parsed root node"""
+ if isinstance(data, str):
+ data = data.encode('utf8')
+ if isinstance(data, bytes):
+ return xml.parse(BytesIO(data)).getroot()
+ return data
+
+def xmldiff(data1, data2):
+ """Create an xml difference, will modify the first xml structure with a diff"""
+ xml1, xml2 = to_xml(data1), to_xml(data2)
+ delta = DeltaLogger()
+ _xmldiff(xml1, xml2, delta)
+ return xml.tostring(xml1).decode('utf-8'), delta
+
+def _xmldiff(xml1, xml2, delta):
+ if xml1.tag != xml2.tag:
+ xml1.tag = '{}XXX{}'.format(xml1.tag, xml2.tag)
+ delta.append_tag(xml1.tag, xml2.tag)
+ for name, value in xml1.attrib.items():
+ if name not in xml2.attrib:
+ delta.append_attr(name, xml1.attrib[name], None)
+ xml1.attrib[name] += "XXX"
+ elif xml2.attrib.get(name) != value:
+ delta.append_attr(name, xml1.attrib.get(name), xml2.attrib.get(name))
+ xml1.attrib[name] = "{}XXX{}".format(xml1.attrib.get(name), xml2.attrib.get(name))
+ for name, value in xml2.attrib.items():
+ if name not in xml1.attrib:
+ delta.append_attr(name, None, value)
+ xml1.attrib[name] = "XXX" + value
+ if not text_compare(xml1.text, xml2.text):
+ delta.append_text(xml1.text, xml2.text)
+ xml1.text = "{}XXX{}".format(xml1.text, xml2.text)
+ if not text_compare(xml1.tail, xml2.tail):
+ delta.append_text(xml1.tail, xml2.tail)
+ xml1.tail = "{}XXX{}".format(xml1.tail, xml2.tail)
+
+ # Get children and pad with nulls
+ children_a = list(xml1)
+ children_b = list(xml2)
+ children_a += [None] * (len(children_a) - len(children_b))
+ children_b += [None] * (len(children_b) - len(children_a))
+
+ for child_a, child_b in zip(children_a, children_b):
+ if child_a is None: # child_b exists
+ child_c = child_b.clone()
+ delta.append_tag(child_c.tag, None)
+ child_c.tag = 'XXX' + child_c.tag
+ xml1.append(child_c)
+ elif child_b is None: # child_a exists
+ delta.append_tag(None, child_a.tag)
+ child_a.tag += 'XXX'
+ else:
+ _xmldiff(child_a, child_b, delta)