bundle: update (2026-01-18)
This commit is contained in:
547
extensions/botbox3000/deps/inkex/tester/__init__.py
Normal file
547
extensions/botbox3000/deps/inkex/tester/__init__.py
Normal 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
|
||||
)
|
||||
10
extensions/botbox3000/deps/inkex/tester/decorators.py
Normal file
10
extensions/botbox3000/deps/inkex/tester/decorators.py
Normal 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"
|
||||
)
|
||||
181
extensions/botbox3000/deps/inkex/tester/filters.py
Normal file
181
extensions/botbox3000/deps/inkex/tester/filters.py
Normal 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"))
|
||||
592
extensions/botbox3000/deps/inkex/tester/inkscape.extension.rng
Normal file
592
extensions/botbox3000/deps/inkex/tester/inkscape.extension.rng
Normal 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>
|
||||
@@ -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>
|
||||
182
extensions/botbox3000/deps/inkex/tester/inx.py
Normal file
182
extensions/botbox3000/deps/inkex/tester/inx.py
Normal 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())
|
||||
459
extensions/botbox3000/deps/inkex/tester/mock.py
Normal file
459
extensions/botbox3000/deps/inkex/tester/mock.py
Normal 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"))
|
||||
55
extensions/botbox3000/deps/inkex/tester/svg.py
Normal file
55
extensions/botbox3000/deps/inkex/tester/svg.py
Normal 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()
|
||||
42
extensions/botbox3000/deps/inkex/tester/word.py
Normal file
42
extensions/botbox3000/deps/inkex/tester/word.py
Normal 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
|
||||
125
extensions/botbox3000/deps/inkex/tester/xmldiff.py
Normal file
125
extensions/botbox3000/deps/inkex/tester/xmldiff.py
Normal 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)
|
||||
Reference in New Issue
Block a user