bundle: update (2026-01-18)

This commit is contained in:
2026-01-18 01:20:18 +00:00
parent 83c22af578
commit 03c5c75177
149 changed files with 38486 additions and 0 deletions

View File

@@ -0,0 +1,547 @@
# 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
)

View File

@@ -0,0 +1,10 @@
"""
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"
)

View File

@@ -0,0 +1,181 @@
#
# 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)) + 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)
contents = re.sub(rb"-0(?=\D|\b)", rb"0", contents) # Replace -0 with 0
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"))

View File

@@ -0,0 +1,592 @@
<?xml version="1.0" encoding="UTF-8"?>
<grammar xmlns="http://relaxng.org/ns/structure/1.0" datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes" ns="http://www.inkscape.org/namespace/inkscape/extension">
<!-- START EXTENSION DESCRIPTION (uses defines below) -->
<start>
<element name="inkscape-extension">
<optional>
<attribute name="translationdomain"/>
</optional>
<element name="name">
<text/>
</element>
<element name="id">
<text/>
</element>
<zeroOrMore>
<element name="description"><text/></element>
</zeroOrMore>
<zeroOrMore>
<element name="category">
<text/>
<optional>
<attribute name="context"/>
</optional>
</element>
</zeroOrMore>
<zeroOrMore>
<ref name="inx.dependency"/>
</zeroOrMore>
<zeroOrMore>
<ref name="inx.widget"/>
</zeroOrMore>
<choice>
<ref name="inx.input_extension"/>
<ref name="inx.output_extension"/>
<ref name="inx.effect_extension"/>
<ref name="inx.path-effect_extension"/>
<ref name="inx.print_extension"/>
<ref name="inx.template_extension"/>
</choice>
<choice>
<ref name="inx.script"/>
<ref name="inx.xslt"/>
<ref name="inx.plugin"/>
</choice>
</element>
</start>
<!-- END EXTENSION DESCRIPTION (uses defines below) -->
<!-- DEPENDENCIES (INCLDUING SCRIPTS, XSLT AND PLUGINS) -->
<define name="inx.dependency">
<element name="dependency">
<optional>
<attribute name="type">
<choice>
<value>file</value> <!-- default if missing -->
<value>executable</value>
<value>extension</value>
</choice>
</attribute>
</optional>
<ref name="inx.dependency.location_attribute"/>
<optional>
<attribute name="description"/>
</optional>
<text/>
</element>
</define>
<define name="inx.script">
<element name="script">
<group>
<element name="command">
<ref name="inx.dependency.location_attribute"/>
<optional>
<attribute name="interpreter">
<choice>
<value>python</value>
<value>perl</value>
</choice>
</attribute>
</optional>
<text/>
</element>
<optional>
<element name="helper_extension">
<data type="NMTOKEN"/>
</element>
</optional>
</group>
</element>
</define>
<define name="inx.xslt">
<element name="xslt">
<element name="file">
<ref name="inx.dependency.location_attribute"/>
<text/>
</element>
</element>
</define>
<define name="inx.plugin">
<!-- TODO: What's this? How/where is it used? -->
<element name="plugin">
<element name="name">
<text/>
</element>
</element>
</define>
<define name="inx.dependency.location_attribute">
<optional>
<attribute name="location">
<choice>
<value>path</value> <!-- default if missing -->
<value>extensions</value>
<value>inx</value>
<value>absolute</value>
</choice>
</attribute>
</optional>
</define>
<!-- EXTENSION TYPES -->
<define name="inx.input_extension">
<element name="input">
<ref name="inx.input_output_extension.common"/>
</element>
</define>
<define name="inx.output_extension">
<element name="output">
<ref name="inx.input_output_extension.common"/>
<optional>
<attribute name="raster">
<ref name="data_type_boolean_strict"/>
</attribute>
</optional>
<optional>
<element name="dataloss">
<ref name="data_type_boolean_strict"/>
</element>
</optional>
<optional>
<element name="savecopyonly">
<ref name="data_type_boolean_strict"/>
</element>
</optional>
</element>
</define>
<define name="inx.input_output_extension.common">
<optional>
<attribute name="priority">
<data type="integer"/>
</attribute>
</optional>
<element name="extension">
<text/>
</element>
<element name="mimetype">
<text/>
</element>
<optional>
<element name="filetypename">
<text/>
</element>
</optional>
<optional>
<element name="filetypetooltip">
<text/>
</element>
</optional>
</define>
<define name="inx.effect_extension">
<element name="effect">
<optional>
<attribute name="needs-document">
<ref name="data_type_boolean_strict"/>
</attribute>
</optional>
<optional>
<attribute name="needs-live-preview">
<ref name="data_type_boolean_strict"/>
</attribute>
</optional>
<optional>
<attribute name="implements-custom-gui">
<ref name="data_type_boolean_strict"/>
</attribute>
</optional>
<optional>
<attribute name="show-stderr">
<ref name="data_type_boolean_strict"/>
</attribute>
</optional>
<element name="object-type">
<choice>
<value type="token">all</value>
<value type="token">g</value>
<value type="token">path</value>
<value type="token">rect</value>
<value type="token">text</value>
</choice>
</element>
<element name="effects-menu">
<choice>
<attribute name="hidden">
<ref name="data_type_boolean_strict"/>
</attribute>
<ref name="inx.effect_extension.submenu"/>
</choice>
</element>
<optional>
<element name="menu-tip">
<text/>
</element>
</optional>
<optional>
<element name="icon">
<text/>
</element>
</optional>
</element>
</define>
<define name="inx.effect_extension.submenu">
<element name="submenu">
<attribute name="name"/>
<optional>
<!-- TODO: This allows arbitrarily deep menu nesting - could/should we limit this? -->
<ref name="inx.effect_extension.submenu"/>
</optional>
</element>
</define>
<define name="inx.path-effect_extension">
<!-- TODO: Are we still using these? -->
<element name="path-effect">
<empty/>
</element>
</define>
<define name="inx.print_extension">
<!-- TODO: Are we still using these? -->
<element name="print">
<empty/>
</element>
</define>
<define name="inx.template_extension">
<element name="template">
<zeroOrMore>
<attribute>
<anyName/>
</attribute>
</zeroOrMore>
<zeroOrMore>
<element name="preset">
<zeroOrMore>
<attribute>
<anyName/>
</attribute>
</zeroOrMore>
</element>
</zeroOrMore>
</element>
</define>
<!-- WIDGETS AND PARAMETERS -->
<define name="inx.widget">
<choice>
<element name="param">
<ref name="inx.widget.common_attributes"/>
<ref name="inx.parameter"/>
</element>
<element name="label">
<ref name="inx.widget.common_attributes"/>
<optional>
<attribute name="appearance">
<choice>
<value>header</value>
<value>url</value>
</choice>
</attribute>
</optional>
<optional>
<attribute name="xml:space">
<choice>
<value>default</value>
<value>preserve</value>
</choice>
</attribute>
</optional>
<text/>
</element>
<element name="hbox">
<ref name="inx.widget.common_attributes"/>
<oneOrMore>
<ref name="inx.widget"/>
</oneOrMore>
</element>
<element name="vbox">
<ref name="inx.widget.common_attributes"/>
<oneOrMore>
<ref name="inx.widget"/>
</oneOrMore>
</element>
<element name="separator">
<ref name="inx.widget.common_attributes"/>
<empty/>
</element>
<element name="spacer">
<ref name="inx.widget.common_attributes"/>
<optional>
<attribute name="size">
<choice>
<data type="integer"/>
<value>expand</value>
</choice>
</attribute>
</optional>
<empty/>
</element>
<element name="image">
<ref name="inx.widget.common_attributes"/>
<optional>
<attribute name="width">
<data type="integer"/>
</attribute>
<attribute name="height">
<data type="integer"/>
</attribute>
</optional>
<text/>
</element>
</choice>
</define>
<define name="inx.parameter">
<ref name="inx.parameter.common_attributes"/>
<choice>
<group>
<attribute name="type">
<value>int</value>
</attribute>
<optional>
<attribute name="min">
<data type="integer"/>
</attribute>
</optional>
<optional>
<attribute name="max">
<data type="integer"/>
</attribute>
</optional>
<optional>
<attribute name="appearance">
<value>full</value>
</attribute>
</optional>
<choice>
<empty/>
<data type="integer"/>
</choice>
</group>
<group>
<attribute name="type">
<value>float</value>
</attribute>
<optional>
<attribute name="precision">
<data type="integer"/>
</attribute>
</optional>
<optional>
<attribute name="min">
<data type="float"/>
</attribute>
</optional>
<optional>
<attribute name="max">
<data type="float"/>
</attribute>
</optional>
<optional>
<attribute name="appearance">
<value>full</value>
</attribute>
</optional>
<data type="float"/>
</group>
<group>
<attribute name="type">
<value>bool</value>
</attribute>
<ref name="data_type_boolean_strict"/>
</group>
<group>
<attribute name="type">
<value>color</value>
</attribute>
<optional>
<attribute name="appearance">
<choice>
<value>colorbutton</value>
</choice>
</attribute>
</optional>
<choice>
<empty/>
<data type="integer"/>
<data type="string"/> <!-- TODO: We want to support unsigned integers in hex notation (e.g. 0x12345678),
and possibly other representations valid for strtoul, not random strings -->
</choice>
</group>
<group>
<attribute name="type">
<value>string</value>
</attribute>
<optional>
<attribute name="max_length">
<data type="integer"/>
</attribute>
</optional>
<optional>
<attribute name="appearance">
<choice>
<value>multiline</value>
</choice>
</attribute>
</optional>
<choice>
<empty/>
<text/>
</choice>
</group>
<group>
<attribute name="type">
<value>path</value>
</attribute>
<attribute name="mode">
<!-- Note: "mode" is actually optional and defaults to "file".
For semantic reasons it makes sense to always include, though. -->
<choice>
<value>file</value>
<value>files</value>
<value>folder</value>
<value>folders</value>
<value>file_new</value>
<value>folder_new</value>
</choice>
</attribute>
<optional>
<attribute name="filetypes"/>
</optional>
<choice>
<empty/>
<text/>
</choice>
</group>
<group>
<attribute name="type">
<value>optiongroup</value>
</attribute>
<attribute name="appearance">
<!-- Note: "appearance" is actually optional and defaults to "radio".
For semantic reasons it makes sense to always include, though. -->
<choice>
<value>combo</value>
<value>radio</value>
</choice>
</attribute>
<oneOrMore>
<choice>
<element name="option">
<optional>
<attribute name="value"/>
</optional>
<optional>
<attribute name="translatable">
<ref name="data_type_boolean_yes_no"/>
</attribute>
</optional>
<optional>
<attribute name="context"/>
</optional>
<text/>
</element>
</choice>
</oneOrMore>
</group>
<group>
<attribute name="type">
<value>notebook</value>
</attribute>
<oneOrMore>
<element name="page">
<attribute name="name"/>
<attribute name="gui-text"/>
<oneOrMore>
<ref name="inx.widget"/>
</oneOrMore>
<optional>
<attribute name="translatable">
<ref name="data_type_boolean_yes_no"/>
</attribute>
</optional>
<optional>
<attribute name="context">
<data type="string"/>
</attribute>
</optional>
</element>
</oneOrMore>
</group>
</choice>
</define>
<define name="inx.widget.common_attributes">
<optional>
<attribute name="gui-hidden">
<ref name="data_type_boolean_strict"/>
</attribute>
</optional>
<optional>
<attribute name="indent">
<data type="integer"/>
</attribute>
</optional>
<optional>
<attribute name="translatable">
<ref name="data_type_boolean_yes_no"/>
</attribute>
</optional>
<optional>
<attribute name="context"/>
</optional>
</define>
<define name="inx.parameter.common_attributes">
<attribute name="name">
<data type="token"/>
</attribute>
<optional>
<!-- TODO: gui-text is mandatory for visible parameters -->
<attribute name="gui-text"/>
</optional>
<optional>
<attribute name="gui-description"/>
</optional>
</define>
<!-- GENERAL DEFINES -->
<define name="data_type_boolean_strict">
<data type="boolean">
<except>
<value>0</value>
<value>1</value>
</except>
</data>
</define>
<define name="data_type_boolean_yes_no">
<choice>
<value>yes</value>
<value>no</value>
</choice>
</define>
</grammar>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<ns uri="http://www.inkscape.org/namespace/inkscape/extension" prefix="inx"/>
<pattern>
<title>duplicateOptionValues</title>
<rule context="//inx:param/inx:option">
<report test="preceding-sibling::inx:option/@value = @value">Warning: @value values should be unique for a given option.</report>
</rule>
</pattern>
</schema>

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Test elements extra logic from svg xml lxml custom classes.
"""
import os
import sys
from importlib import resources
from lxml import etree, isoschematron
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}",
)
inxchoices = params[param]["choices"]
argschoices = args[param]["choices"]
if argschoices is not None and len(argschoices) > 0:
assert set(inxchoices).issubset(argschoices), (
f"params don't match: inx={inxchoices}, py={argschoices}"
)
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,
}
def assertInxSchemaValid(self, inx_file): # pylint: disable=invalid-name
"""Validate inx file schema."""
self.assertTrue(INX_SCHEMAS, "no schema files found")
with open(inx_file, "rb") as fp:
inx_doc = etree.parse(fp)
for schema_name, schema in INX_SCHEMAS.items():
with self.subTest(schema_file=schema_name):
schema.assert_(inx_doc)
def _load_inx_schemas():
_SCHEMA_CLASSES = {
".rng": etree.RelaxNG,
".schema": isoschematron.Schematron,
}
if sys.version_info > (3, 9):
def _contents(pkg):
return [path.name for path in resources.files(pkg).iterdir()]
else:
_contents = resources.contents
for name in _contents(__package__):
_, ext = os.path.splitext(name)
schema_class = _SCHEMA_CLASSES.get(ext)
if schema_class is None:
continue
if sys.version_info > (3, 9):
def _open_binary(pkg, res):
return resources.files(pkg).joinpath(res).open("rb")
else:
_open_binary = resources.open_binary
with _open_binary(__package__, name) as fp:
schema_doc = etree.parse(fp)
yield name, schema_class(schema_doc)
INX_SCHEMAS = dict(_load_inx_schemas())

View File

@@ -0,0 +1,459 @@
# 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 + os.sep, "./")
data = replace(data, fdir, ".")
files = replace(files, fdir + os.sep, "./")
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, path):
"""Return true if the mock is ignored"""
if self and program and arglst:
env = os.environ.get("NO_MOCK_COMMANDS", 0)
if (not os.path.exists(path) and int(env) == 1) or int(env) == 2:
return True
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, self.get_call_filename(program, key, create=True)
):
# 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"))
if int(os.environ.get("NO_MOCK_COMMANDS", 0)) == 1:
print(f"Saved mock call as {fname}, remove .{ext}")
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"))

View File

@@ -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 <svg> element of a minimal SVG document.
"""
return etree.fromstring(
str.encode(
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>'
f"<svg {svg_attrs}></svg>"
),
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()

View File

@@ -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

View File

@@ -0,0 +1,125 @@
#
# 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
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)