try a python script in runner
This commit is contained in:
@@ -1,547 +0,0 @@
|
||||
# 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 inkex.extensions import OutputExtension
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def export_svg(self, document, *args) -> str:
|
||||
"""Runs a svg through an export extension, with optional arguments
|
||||
provided as "--arg=value" arguments"""
|
||||
assert isinstance(self, OutputExtension)
|
||||
output = StringIO()
|
||||
writer = self.effect_class()
|
||||
writer.parse_arguments([*args])
|
||||
writer.svg = document.getroot()
|
||||
writer.document = document
|
||||
writer.effect()
|
||||
writer.save(output)
|
||||
output.seek(0)
|
||||
return output.read()
|
||||
|
||||
|
||||
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 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)
|
||||
|
||||
@staticmethod
|
||||
def create_test_method(comparison, file, addout):
|
||||
def _test_method(self):
|
||||
self._test_comparison(comparison, file, addout)
|
||||
|
||||
return _test_method
|
||||
|
||||
|
||||
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
|
||||
|
||||
def _test_comparisons(self, compare_file, addout=None):
|
||||
for args in self.comparisons:
|
||||
self._test_comparison(args, compare_file=compare_file, addout=addout)
|
||||
|
||||
def _test_comparison(self, args, compare_file, addout=None):
|
||||
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")
|
||||
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
|
||||
|
||||
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}")
|
||||
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user