Source code for inkex.tester

# 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
import io
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, load_svg, SvgDocumentElement
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)


[docs]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__)
[docs] def run(self, args=None, output=None): """Fake run"""
[docs]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
[docs] def setUp(self): # pylint: disable=invalid-name """Make sure every test is seeded the same way""" self._effect = None super().setUp() random.seed(0x35F)
[docs] 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__)
[docs] @classmethod def _testdir(cls): """Get's the folder where the test exists (so data can be found)""" return os.path.dirname(cls.__file__())
[docs] @classmethod def rootdir(cls): """Return the full path to the extensions directory""" return os.path.dirname(cls._testdir())
[docs] @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
[docs] 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)
[docs] @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")
[docs] 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)
[docs] def assertEffectEmpty(self, effect, **kwargs): # pylint: disable=invalid-name """Assert calling effect without any arguments""" self.assertEffect(effect=effect, **kwargs)
[docs] 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
[docs] 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)
[docs] 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
[docs] def import_string(self, string, *args) -> SvgDocumentElement: """Runs a string through an import extension, with optional arguments provided as "--arg=value" arguments""" stream = io.BytesIO(string.encode()) reader = self.effect_class() out = io.BytesIO() reader.parse_arguments([*args]) reader.options.input_file = stream reader.options.output = out reader.load_raw() reader.save_raw(reader.effect()) out.seek(0) decoded = out.read().decode("utf-8") document = load_svg(decoded) return document
[docs]class InkscapeExtensionTestMixin: """Automatically setup self.effect for each test and test with an empty svg"""
[docs] 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")
[docs] def test_default_settings(self): """Extension works with empty svg file""" self.effect.run([self.empty_svg])
[docs]class ComparisonMeta(type): """Metaclass for ComparisonMixin which creates parametrized tests that can be run independently. See :class:`~inkex.tester.ComparisonMixin` for details. ..versionadded :: 1.4""" def __init__(cls, name, bases, attrs): super().__init__(name, bases, attrs) if name == "ComparisonMixin": return # don't execute on the base class _compare_file = cls.compare_file _comparisons = cls.comparisons if hasattr(cls, "comparisons_cmpfile_dict"): _comparisons = cls.comparisons_cmpfile_dict.keys() def get_compare_cmpfile(self, args, addout=None): return self.data_file("refs", cls.comparisons_cmpfile_dict[args]) setattr(cls, "get_compare_cmpfile", get_compare_cmpfile) test_method_name = f"test_all_comparisons" if hasattr(cls, test_method_name): return # Custom test logic, don't touch append = isinstance(_compare_file, (list, tuple)) compare_file = _compare_file if append else [_compare_file] try: for file in compare_file: for comparison in _comparisons: test_method_name = f"test_comparison_{comparison}_{file}" setattr( cls, test_method_name, ComparisonMeta.create_test_method( comparison, file, os.path.basename(file) if append else None, ), ) except TypeError: # If the compare_file or compare_ test_method_name = f"test_all_comparisons" def test_method(self): 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) ) setattr(cls, test_method_name, test_method)
[docs] @staticmethod def create_test_method(comparison, file, addout): def _test_method(self): self._test_comparison(comparison, file, addout) return _test_method
[docs]class ComparisonMixin(metaclass=ComparisonMeta): """ This mixin allows to easily specify a set of run-through unit tests for an extension, which is specified in :attr:`inkex.tester.TestCase.effect_class`. The commandline parameters are passed in :attr:`comparisons`, the input file in :attr:`compare_file` (either a list of files, or a single file). The :class:`ComparisonMeta` metaclass creates a set of independent unit tests out of this data. Behavior notest: - The unit tests created are the cross product of :attr:`comparisons` and :attr:`compare_file`. If :attr:`compare_file` is a list, the comparison file name is suffixed with the current ``compare_file`` name. - Optionally, :attr:`comparisons_cmpfile_dict` may be specified as ``Dict[Tuple[str], str]`` where the keys are sets of command line parameters and the values are the filenames of the output file. This takes precedence over :attr:`comparisons`. - If any of those values are properties, their values cannot be accessed at test collection time and there will only be a single test, ``test_all_comparisons`` with otherwise identical behavior. - If the class overrides ``test_all_comparisons``, no additional tests are generated to allow for custom comparison logic. To create the comparison files for the unit tests, use the ``EXPORT_COMPARE`` environment variable. """ 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
[docs] def _test_comparisons(self, compare_file, addout=None): for args in self.comparisons: self._test_comparison(args, compare_file=compare_file, addout=addout)
[docs] def _test_comparison(self, args, compare_file, addout=None): self.assertCompare( compare_file, self.get_compare_cmpfile(args, addout), args, )
[docs] 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") self.write_compare_data(infile, write_output, data_a) # 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
[docs] def write_compare_data(self, infile, outfile, data): """Write output""" with open(outfile, "wb") as fhl: fhl.write(self._apply_compare_filters(data, True)) print(f"Written output: {outfile}")
[docs] 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)
[docs] 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
[docs] 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 )