From cca66b9ec4e494c1d919bff0f71a820d8afab1fa Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 20:24:48 +0200 Subject: Adding upstream version 1.2.2. Signed-off-by: Daniel Baumann --- share/extensions/inkex/tester/__init__.py | 437 ++++++++++++++++++++++++++ share/extensions/inkex/tester/decorators.py | 9 + share/extensions/inkex/tester/filters.py | 180 +++++++++++ share/extensions/inkex/tester/inx.py | 128 ++++++++ share/extensions/inkex/tester/mock.py | 455 ++++++++++++++++++++++++++++ share/extensions/inkex/tester/svg.py | 55 ++++ share/extensions/inkex/tester/word.py | 42 +++ share/extensions/inkex/tester/xmldiff.py | 124 ++++++++ 8 files changed, 1430 insertions(+) create mode 100644 share/extensions/inkex/tester/__init__.py create mode 100644 share/extensions/inkex/tester/decorators.py create mode 100644 share/extensions/inkex/tester/filters.py create mode 100644 share/extensions/inkex/tester/inx.py create mode 100644 share/extensions/inkex/tester/mock.py create mode 100644 share/extensions/inkex/tester/svg.py create mode 100644 share/extensions/inkex/tester/word.py create mode 100644 share/extensions/inkex/tester/xmldiff.py (limited to 'share/extensions/inkex/tester') diff --git a/share/extensions/inkex/tester/__init__.py b/share/extensions/inkex/tester/__init__.py new file mode 100644 index 0000000..f7e1d4b --- /dev/null +++ b/share/extensions/inkex/tester/__init__.py @@ -0,0 +1,437 @@ +# 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. +# +""" +Testing module. See :ref:`unittests` for details. +""" + +import os +import re +import sys +import shutil +import tempfile +import hashlib +import random +import uuid +from typing import List, Union, Tuple, Type, TYPE_CHECKING + +from io import BytesIO, StringIO +import xml.etree.ElementTree as xml + +from unittest import TestCase as BaseCase +from inkex.base import InkscapeExtension + +from .. import Transform +from ..utils import to_bytes +from .xmldiff import xmldiff +from .mock import MockCommandMixin, Capture + +if TYPE_CHECKING: + from .filters import Compare + +COMPARE_DELETE, COMPARE_CHECK, COMPARE_WRITE, COMPARE_OVERWRITE = range(4) + + +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""" + + +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 + + def __init__(self, *args, **kw): + super().__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().setUp() + random.seed(0x35F) + + def tearDown(self): + super().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 = os.path.realpath(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, check_exists=True): + """Provide a data file from a filename, can accept directories as arguments. + + .. versionchanged:: 1.2 + ``check_exists`` parameter added""" + 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) and check_exists: + raise IOError(f"Can't find test data file: {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, msg="" + ): # 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), msg) + for fon, exp in zip(found, expected): + self.assertAlmostEqual(fon, exp, precision, msg) + + 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 + """ + if filename: + data_file = self.data_file(*filename) + else: + data_file = self.empty_svg + + os.environ["DOCUMENT_PATH"] = data_file + args = [data_file] + list(kwargs.pop("args", [])) + args += [f"--{kw[0]}={kw[1]}" for kw in kwargs.items()] + + effect = kwargs.pop("effect", self.effect_class)() + + # 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 + + # pylint: disable=invalid-name + def assertDeepAlmostEqual(self, first, second, places=None, msg=None, delta=None): + """Asserts that two objects, possible nested lists, are almost equal.""" + 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) + + def assertTransformEqual(self, lhs, rhs, places=7): + """Assert that two transform expressions evaluate to the same + transformation matrix. + + .. versionadded:: 1.1 + """ + self.assertAlmostTuple( + tuple(Transform(lhs).to_hexad()), tuple(Transform(rhs).to_hexad()), places + ) + + # pylint: enable=invalid-name + + @property + def effect(self): + """Generate an effect object""" + if self._effect is None: + self._effect = self.effect_class() + return self._effect + + +class InkscapeExtensionTestMixin: + """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().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: + """ + Add comparison tests to any existing test suite. + """ + + compare_file: Union[List[str], Tuple[str], str] = "svg/shapes.svg" + """This input svg file sent to the extension (if any)""" + + compare_filters = [] # type: List[Compare] + """The ways in which the output is filtered for comparision (see filters.py)""" + + compare_filter_save = False + """If true, the filtered output will be saved and only applied to the + extension output (and not to the reference file)""" + + comparisons = [ + (), + ("--id=p1", "--id=r3"), + ] + """A list of comparison runs, each entry will cause the extension to be run.""" + + compare_file_extension = "svg" + + @property + def _compare_file_extension(self): + """The default extension to use when outputting check files in COMPARE_CHECK + mode.""" + if self.stderr_output: + return "txt" + return self.compare_file_extension + + 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_cmpfile(args, addout), + args, + ) + + def assertCompare( + self, infile, cmpfile, args, outfile=None + ): # pylint: disable=invalid-name + """ + Compare the output of a previous run against this one. + + Args: + infile: The filename of the pre-processed svg (or other type of file) + cmpfile: 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 + outfile: Optional, instead of returning a regular output, this extension + dumps it's output to this filename instead. + + """ + compare_mode = int(os.environ.get("EXPORT_COMPARE", COMPARE_DELETE)) + + effect = self.assertEffect(infile, args=args) + + if cmpfile is None: + cmpfile = self.get_compare_cmpfile(args) + + if not os.path.isfile(cmpfile) and compare_mode == COMPARE_DELETE: + raise IOError( + f"Comparison file {cmpfile} not found, set EXPORT_COMPARE=1 to create " + "it." + ) + + if outfile: + if not os.path.isabs(outfile): + outfile = os.path.join(self.tempdir, outfile) + self.assertTrue( + os.path.isfile(outfile), f"No output file created! {outfile}" + ) + with open(outfile, "rb") as fhl: + data_a = fhl.read() + else: + data_a = effect.test_output.getvalue() + + write_output = None + if compare_mode == COMPARE_CHECK: + _file = cmpfile[:-4] if cmpfile.endswith(".out") else cmpfile + write_output = f"{_file}.{self._compare_file_extension}" + elif ( + compare_mode == COMPARE_WRITE and not os.path.isfile(cmpfile) + ) or compare_mode == COMPARE_OVERWRITE: + write_output = cmpfile + + try: + if write_output and not os.path.isfile(cmpfile): + raise AssertionError(f"Check the output: {write_output}") + with open(cmpfile, "rb") as fhl: + data_b = self._apply_compare_filters(fhl.read(), False) + self._base_compare(data_a, data_b, compare_mode) + except AssertionError: + if write_output: + if isinstance(data_a, str): + data_a = data_a.encode("utf-8") + with open(write_output, "wb") as fhl: + fhl.write(self._apply_compare_filters(data_a, True)) + print(f"Written output: {write_output}") + # This only reruns if the original test failed. + # The idea here is to make sure the new output file is "stable" + # Because some tests can produce random changes and we don't + # want test authors to be too reassured by a simple write. + if write_output == cmpfile: + effect = self.assertEffect(infile, args=args) + self._base_compare(data_a, cmpfile, COMPARE_CHECK) + if not write_output == cmpfile: + raise + + def _base_compare(self, data_a, data_b, compare_mode): + data_a = self._apply_compare_filters(data_a) + + 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 compare_mode == COMPARE_DELETE: + print( + "The XML is different, you can save the output using the " + "EXPORT_COMPARE envionment variable. Set it to 1 to save a file " + "you can check, set it to 3 to overwrite this comparison, setting " + "the new data as the correct one.\n" + ) + diff = "SVG Differences\n\n" + 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 += f" {x}. {str(err)}\n" + 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_cmpfile(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", f"{self.effect_name}{opstr}.out", check_exists=False + ) diff --git a/share/extensions/inkex/tester/decorators.py b/share/extensions/inkex/tester/decorators.py new file mode 100644 index 0000000..f27e638 --- /dev/null +++ b/share/extensions/inkex/tester/decorators.py @@ -0,0 +1,9 @@ +""" +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..281adfc --- /dev/null +++ b/share/extensions/inkex/tester/filters.py @@ -0,0 +1,180 @@ +# +# 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. + +.. code-block:: python + + compare_filters = [ + CompareNumericFuzzy(), + CompareOrderIndependentLines(option=yes), + ] + +""" + +import re +from ..utils import to_bytes + + +class Compare: + """ + 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(rb"\d+\.\d+(e[+-]\d+)?", func, contents) + contents = re.sub(rb"(\d\.\d+?)0+\b", rb"\1", contents) + contents = re.sub(rb"(\d)\.0+(?=\D|\b)", rb"\1", contents) + return contents + + +class CompareWithoutIds(Compare): + """Remove all ids from the svg""" + + @staticmethod + def filter(contents): + return re.sub(rb' 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(rb"\s*([LZMHVCSQTAatqscvhmzl])\s*", rb" \1 ", match.group(1)) + return b' d="' + new.replace(b",", b" ") + b'"' + + return re.sub(rb' 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(rb'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(rb"[A-Z]", match.group(1)))) + return b'd="%s"' % (path,) + + return re.sub(rb'\bd="([^"]*)"', func, contents) + + +class CompareOrderIndependentTags(Compare): + """Sorts all the XML tags""" + + @staticmethod + def filter(contents): + return b"\n".join(sorted(re.split(rb">\s*<", contents))) + + +class CompareReplacement(Compare): + """Replace pieces to make output more comparable + + .. versionadded:: 1.1""" + + def __init__(self, *replacements): + self.deltas = replacements + super().__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 + + +class WindowsTextCompat(CompareReplacement): + """Normalize newlines so tests comparing plain text work + + .. versionadded:: 1.2""" + + def __init__(self): + super().__init__(("\r\n", "\n")) diff --git a/share/extensions/inkex/tester/inx.py b/share/extensions/inkex/tester/inx.py new file mode 100644 index 0000000..bad6d81 --- /dev/null +++ b/share/extensions/inkex/tester/inx.py @@ -0,0 +1,128 @@ +#!/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: + """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, f"Can not find class for {inx.filename}") + # Check name is reasonable for the class + if not cls.multi_inx: + self.assertEqual( + cls.__name__, + inx.slug, + f"Name of extension class {cls.__module__}.{cls.__name__} " + f"is different from ident {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 + + .. versionchanged:: 1.2 + Also checks that the default values are identical""" + params = {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, f"{inx.filename}: Inx params missing from arg parser" + ) + self.assertFalse( + mismatch_b, f"{inx.filename}: Script args missing from inx xml" + ) + + for param in args: + if params[param]["type"] and args[param]["type"]: + self.assertEqual( + params[param]["type"], + args[param]["type"], + f"Type is not the same for {inx.filename}:param:{param}", + ) + inxdefault = params[param]["default"] + argsdefault = args[param]["default"] + if inxdefault and argsdefault: + # for booleans, the inx is lowercase and the param is uppercase + if params[param]["type"] == "bool": + argsdefault = str(argsdefault).lower() + elif params[param]["type"] not in ["string", None, "color"] or args[ + param + ]["type"] in ["int", "float"]: + # try to parse the inx value to compare numbers to numbers + inxdefault = float(inxdefault) + if args[param]["type"] == "color" or callable(args[param]["default"]): + # skip color, method types + continue + self.assertEqual( + argsdefault, + inxdefault, + f"Default value is not the same for {inx.filename}:param:{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..3b75dcd --- /dev/null +++ b/share/extensions/inkex/tester/mock.py @@ -0,0 +1,455 @@ +# 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 typing import List, Tuple, Any + +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 + + +FIXED_BOUNDARY = "--CALLDATA--//--CALLDATA--" + + +class Capture: + """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): + 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: + """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: + """ + 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().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().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().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(os.path.realpath(newdir)) + return newdir + + def clean_paths(self, data, files): + """Clean a string of any files or tempdirs""" + + def replace(indata, replaced, replacement): + if isinstance(indata, str): + indata = indata.replace(replaced, replacement) + else: + indata = [i.replace(replaced, replacement) for i in indata] + return indata + + try: + for fdir in self.recorded_tempdirs: + data = replace(data, fdir, ".") + files = replace(files, fdir, ".") + for fname in files: + data = replace(data, 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 + f";{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"] = MockCommandMixin.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_sorted(program, *args, **kwargs)[1:] + arglst = self.clean_paths(arglst, inputs + outputs) + argstr = " ".join(arglst) + msg["Arguments"] = argstr.strip() + + if stdin is not None: + # The stdin is counted as the msg body + cleanin = ( + self.clean_paths(stdin, inputs + outputs) + .replace("\r\n", "\n") + .replace(".\\", "./") + ) + msg.attach(MIMEText(cleanin, "plain", "utf-8")) + + keystr = msg.as_string() + # On Windows, output is separated by CRLF + keystr = keystr.replace("\r\n", "\n") + # 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"] = MockCommandMixin.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 as err: + self.save_key(program, key, keystr, "bad-key") + raise IOError( + f"Problem loading call: {program}/{key} use the environment variable " + "NO_MOCK_COMMANDS=1 to call out to the external program and generate " + f"the mock call file for call {program} {argstr}." + ) from err + + 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() + try: + value = value.decode() + except UnicodeDecodeError: + pass # do not attempt to process binary files further + if isinstance(value, str): + value = value.replace("\r\n", "\n").replace(".\\", "./") + 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(f"Attempted to find call test data {key}") + return fname + + @staticmethod + def get_program_name(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(), MockCommandMixin.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:" + f" {program}; but there is not a command data directory which " + f"should contain the results of the command here: {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..c601c81 --- /dev/null +++ b/share/extensions/inkex/tester/svg.py @@ -0,0 +1,55 @@ +# 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 element of a minimal SVG document. + """ + return etree.fromstring( + str.encode( + '' + f"" + ), + parser=SVG_PARSER, + ) + + +def svg_unit_scaled(width_unit): + """Same as svg, but takes a width unit (top-level transform) for the new document. + + The transform is the ratio between the SVG width and the viewBox width. + """ + return svg(f'width="1{width_unit}" viewBox="0 0 1 1"') + + +def svg_file(filename): + """Parse an svg file and return it's document root""" + with open(filename, "r", encoding="utf-8") 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..bf395d7 --- /dev/null +++ b/share/extensions/inkex/tester/word.py @@ -0,0 +1,42 @@ +# 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..1864530 --- /dev/null +++ b/share/extensions/inkex/tester/xmldiff.py @@ -0,0 +1,124 @@ +# +# Copyright 2011 (c) Ian Bicking +# 2019 (c) Martin Owens +# +# 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 + +from inkex.paths import Path + + +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 = f"<{tag_a}.../>" + if tag_b: + tag_b = f"<{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": + return [attr] + Path(val).to_arrays() + return (attr, val) + return val + + # Only append a difference if the preprocessed values are different. + # This solves the issue that -0 != 0 in path data. + prep_a = _prep(value_a) + prep_b = _prep(value_b) + if prep_a != prep_b: + self.append((prep_a, prep_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 f"{len(self)} xml differences" + + +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 = f"{xml1.tag}XXX{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] = f"{xml1.attrib.get(name)}XXX{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 = f"{xml1.text}XXX{xml2.text}" + if not text_compare(xml1.tail, xml2.tail): + delta.append_text(xml1.tail, xml2.tail) + xml1.tail = f"{xml1.tail}XXX{xml2.tail}" + + # Get children and pad with nulls + children_a = list(xml1) + children_b = list(xml2) + children_a += [None] * (len(children_b) - len(children_a)) + children_b += [None] * (len(children_a) - len(children_b)) + + for child_a, child_b in zip(children_a, children_b): + if child_a is None: # child_b exists + delta.append_tag(child_b.tag, None) + elif child_b is None: # child_a exists + delta.append_tag(None, child_a.tag) + else: + _xmldiff(child_a, child_b, delta) -- cgit v1.2.3