# $Id$ # Authors: David Goodger ; # Garth Kidd # Copyright: This module has been placed in the public domain. """ Exports the following: :Modules: - `statemachine` is 'docutils.statemachine' - `nodes` is 'docutils.nodes' - `urischemes` is 'docutils.utils.urischemes' - `utils` is 'docutils.utils' - `transforms` is 'docutils.transforms' - `states` is 'docutils.parsers.rst.states' - `tableparser` is 'docutils.parsers.rst.tableparser' :Classes: - `StandardTestCase` - `CustomTestCase` - `CustomTestSuite` - `TransformTestCase` - `TransformTestSuite` - `ParserTestCase` - `ParserTestSuite` - `ParserTransformTestCase` - `PEPParserTestCase` - `PEPParserTestSuite` - `GridTableParserTestCase` - `GridTableParserTestSuite` - `SimpleTableParserTestCase` - `SimpleTableParserTestSuite` - `WriterPublishTestCase` - `LatexWriterPublishTestCase` - `PseudoXMLWriterPublishTestCase` - `HtmlWriterPublishTestCase` - `PublishTestSuite` - `HtmlFragmentTestSuite` - `DevNull` (output sink) """ __docformat__ = 'reStructuredText' import sys import os import unittest import re import inspect import traceback from pprint import pformat testroot = os.path.abspath(os.path.dirname(__file__) or os.curdir) os.chdir(testroot) sys.path.insert(0, os.path.normpath(os.path.join(testroot, '..'))) sys.path.append(os.path.normpath(os.path.join(testroot, '..', 'extras'))) sys.path.insert(0, testroot) try: import difflib import package_unittest import docutils import docutils.core from docutils import frontend, nodes, statemachine, utils # noqa: F401 from docutils.utils import urischemes # noqa: F401 from docutils.transforms import universal from docutils.parsers import rst from docutils.parsers.rst import states, tableparser, roles, languages # noqa: F401, E501 from docutils.readers import standalone, pep # noqa: F401 from docutils.statemachine import StringList, string2lines except ImportError: # The importing module (usually __init__.py in one of the # subdirectories) may catch ImportErrors in order to detect the # absence of DocutilsTestSupport in sys.path. Thus, ImportErrors # resulting from problems with importing Docutils modules must # caught here. traceback.print_exc() sys.exit(1) try: import mypdb as pdb except ImportError: import pdb # Hack to make repr(StringList) look like repr(list): StringList.__repr__ = StringList.__str__ class DevNull: """Output sink.""" def write(self, string): pass def close(self): pass class StandardTestCase(unittest.TestCase): """ Helper class, providing the same interface as unittest.TestCase, but with useful setUp and comparison methods. The methods assertEqual and assertNotEqual have been overwritten to provide better support for multi-line strings. """ def setUp(self): os.chdir(testroot) def assertEqual(self, first, second, msg=None): """Fail if the two objects are unequal as determined by the '==' operator. """ if not first == second: raise self.failureException( msg or '%s != %s' % _format_str(first, second)) def assertNotEqual(self, first, second, msg=None): """Fail if the two objects are equal as determined by the '==' operator. """ if first == second: raise self.failureException( msg or '%s == %s' % _format_str(first, second)) # aliases for assertion methods, deprecated since Python 2.7 failUnlessEqual = assertEquals = assertEqual assertNotEquals = failIfEqual = assertNotEqual class CustomTestCase(StandardTestCase): """ Helper class, providing extended functionality over unittest.TestCase. See the compare_output method and the parameter list of __init__. Note: the modified signature is incompatible with the "pytest" and "nose" frameworks. """ # cf. feature-request #81 compare = difflib.Differ().compare """Comparison method shared by all subclasses.""" def __init__(self, method_name, input, expected, id, run_in_debugger=True, suite_settings=None): """ Initialise the CustomTestCase. Arguments: method_name -- name of test method to run. input -- input to the parser. expected -- expected output from the parser. id -- unique test identifier, used by the test framework. run_in_debugger -- if true, run this test under the pdb debugger. suite_settings -- settings overrides for this test suite. """ self.id = id self.input = input self.expected = expected self.run_in_debugger = run_in_debugger self.suite_settings = suite_settings.copy() if suite_settings else {} super().__init__(method_name) def __str__(self): """ Return string conversion. Overridden to give test id, in addition to method name. """ return '%s; %s' % (self.id, unittest.TestCase.__str__(self)) def __repr__(self): return "<%s %s>" % (self.id, unittest.TestCase.__repr__(self)) def clear_roles(self): # Language-specific roles and roles added by the # "default-role" and "role" directives are currently stored # globally in the roles._roles dictionary. This workaround # empties that dictionary. roles._roles = {} def setUp(self): StandardTestCase.setUp(self) self.clear_roles() def compare_output(self, input, output, expected): """`input` should by bytes, `output` and `expected` strings.""" if isinstance(input, str): input = input.encode('raw_unicode_escape') if isinstance(expected, bytes): expected = expected.decode('utf-8') if isinstance(output, bytes): output = output.decode('utf-8') # Normalize line endings: if expected: expected = '\n'.join(expected.splitlines()) if output: output = '\n'.join(output.splitlines()) try: self.assertEqual(output, expected) except AssertionError as error: print('\n%s\ninput:' % (self,), file=sys.stderr) print(input, file=sys.stderr) try: comparison = ''.join(self.compare(expected.splitlines(True), output.splitlines(True))) print('-: expected\n+: output', file=sys.stderr) print(comparison, file=sys.stderr) except AttributeError: # expected or output not a string # alternative output for non-strings: print('expected: %r' % expected, file=sys.stderr) print('output: %r' % output, file=sys.stderr) raise error class CustomTestSuite(unittest.TestSuite): """ A collection of CustomTestCases. Provides test suite ID generation and a method for adding test cases. """ id = '' """Identifier for the TestSuite. Prepended to the TestCase identifiers to make identification easier.""" next_test_case_id = 0 """The next identifier to use for non-identified test cases.""" def __init__(self, tests=(), id=None, suite_settings=None): """ Initialize the CustomTestSuite. Arguments: id -- identifier for the suite, prepended to test cases. suite_settings -- settings overrides for this test suite. """ unittest.TestSuite.__init__(self, tests) self.suite_settings = suite_settings or {} if id is None: mypath = os.path.abspath( sys.modules[CustomTestSuite.__module__].__file__) outerframes = inspect.getouterframes(inspect.currentframe()) for outerframe in outerframes[1:]: if outerframe[3] != '__init__': callerpath = outerframe[1] if callerpath is None: # It happens sometimes. Why is a mystery. callerpath = os.getcwd() callerpath = os.path.abspath(callerpath) break mydir, myname = os.path.split(mypath) if not mydir: mydir = os.curdir if callerpath.startswith(mydir): self.id = callerpath[len(mydir) + 1:] # caller's module else: self.id = callerpath else: self.id = id def addTestCase(self, test_case_class, method_name, input, expected, id=None, run_in_debugger=False, **kwargs): """ Create a CustomTestCase in the CustomTestSuite. Also return it, just in case. Arguments: test_case_class -- the CustomTestCase to add method_name -- a string; CustomTestCase.method_name is the test input -- input to the parser. expected -- expected output from the parser. id -- unique test identifier, used by the test framework. run_in_debugger -- if true, run this test under the pdb debugger. """ if id is None: # generate id if required id = self.next_test_case_id self.next_test_case_id += 1 # test identifier will become suiteid.testid tcid = '%s: %s' % (self.id, id) # suite_settings may be passed as a parameter; # if not, set from attribute: kwargs.setdefault('suite_settings', self.suite_settings) # generate and add test case tc = test_case_class(method_name, input, expected, tcid, run_in_debugger=run_in_debugger, **kwargs) self.addTest(tc) return tc def generate_no_tests(self, *args, **kwargs): pass class TransformTestCase(CustomTestCase): """ Output checker for the transform. Should probably be called TransformOutputChecker, but I can deal with that later when/if someone comes up with a category of transform test cases that have nothing to do with the input and output of the transform. """ settings = frontend.get_default_settings(rst.Parser) settings.report_level = 1 settings.halt_level = 5 settings.debug = package_unittest.debug settings.warning_stream = DevNull() unknown_reference_resolvers = () def __init__(self, *args, parser=None, transforms=None, **kwargs): assert transforms is not None, 'required argument' self.transforms = transforms """List of transforms to perform for this test case.""" assert parser is not None, 'required argument' self.parser = parser """Input parser for this test case.""" CustomTestCase.__init__(self, *args, **kwargs) def supports(self, format): return True def test_transforms(self): if self.run_in_debugger: pdb.set_trace() settings = self.settings.copy() settings.__dict__.update(self.suite_settings) document = utils.new_document('test data', settings) self.parser.parse(self.input, document) # Don't do a ``populate_from_components()`` because that would # enable the Transformer's default transforms. document.transformer.add_transforms(self.transforms) document.transformer.add_transform(universal.TestMessages) document.transformer.components['writer'] = self document.transformer.apply_transforms() output = document.pformat() self.compare_output(self.input, output, self.expected) def test_transforms_verbosely(self): if self.run_in_debugger: pdb.set_trace() print('\n', self.id) print('-' * 70) print(self.input) settings = self.settings.copy() settings.__dict__.update(self.suite_settings) document = utils.new_document('test data', settings) self.parser.parse(self.input, document) print('-' * 70) print(document.pformat()) for transformClass in self.transforms: transformClass(document).apply() output = document.pformat() print('-' * 70) print(output) self.compare_output(self.input, output, self.expected) class TransformTestSuite(CustomTestSuite): """ A collection of TransformTestCases. A TransformTestSuite instance manufactures TransformTestCases, keeps track of them, and provides a shared test fixture (a-la setUp and tearDown). """ def __init__(self, parser, suite_settings=None): self.parser = parser """Parser shared by all test cases.""" CustomTestSuite.__init__(self, suite_settings=suite_settings) def generateTests(self, dict, dictname='totest', testmethod='test_transforms'): """ Stock the suite with test cases generated from a test data dictionary. Each dictionary key (test type's name) maps to a tuple, whose first item is a list of transform classes and whose second item is a list of tests. Each test is a list: input, expected output, optional modifier. The optional third entry, a behavior modifier, can be 0 (temporarily disable this test) or 1 (run this test under the pdb debugger). Tests should be self-documenting and not require external comments. """ for name, (transforms, cases) in dict.items(): for casenum in range(len(cases)): case = cases[casenum] run_in_debugger = False if len(case) == 3: # TODO: (maybe) change the 3rd argument to a dict, so it # can handle more cases by keyword ('disable', 'debug', # 'settings'), here and in other generateTests methods. # But there's also the method that # HtmlPublishPartsTestSuite uses if case[2]: run_in_debugger = True else: continue self.addTestCase( TransformTestCase, testmethod, transforms=transforms, parser=self.parser, input=case[0], expected=case[1], id='%s[%r][%s]' % (dictname, name, casenum), run_in_debugger=run_in_debugger) class ParserTestCase(CustomTestCase): """ Output checker for the parser. Should probably be called ParserOutputChecker, but I can deal with that later when/if someone comes up with a category of parser test cases that have nothing to do with the input and output of the parser. """ parser = rst.Parser() """Parser shared by all ParserTestCases.""" settings = frontend.get_default_settings(rst.Parser) settings.report_level = 5 settings.halt_level = 5 settings.debug = package_unittest.debug def test_parser(self): if self.run_in_debugger: pdb.set_trace() settings = self.settings.copy() settings.__dict__.update(self.suite_settings) document = utils.new_document('test data', settings) self.parser.parse(self.input, document) output = document.pformat() self.compare_output(self.input, output, self.expected) class ParserTestSuite(CustomTestSuite): """ A collection of ParserTestCases. A ParserTestSuite instance manufactures ParserTestCases, keeps track of them, and provides a shared test fixture (a-la setUp and tearDown). """ test_case_class = ParserTestCase def generateTests(self, dict, dictname='totest'): """ Stock the suite with test cases generated from a test data dictionary. Each dictionary key (test type name) maps to a list of tests. Each test is a list: input, expected output, optional modifier. The optional third entry, a behavior modifier, can be 0 (temporarily disable this test) or 1 (run this test under the pdb debugger). Tests should be self-documenting and not require external comments. """ for name, cases in dict.items(): for casenum in range(len(cases)): case = cases[casenum] run_in_debugger = False if len(case) == 3: if case[2]: run_in_debugger = True else: continue self.addTestCase( self.test_case_class, 'test_parser', input=case[0], expected=case[1], id='%s[%r][%s]' % (dictname, name, casenum), run_in_debugger=run_in_debugger) class PEPParserTestCase(ParserTestCase): """PEP-specific parser test case.""" parser = rst.Parser(rfc2822=True, inliner=rst.states.Inliner()) """Parser shared by all PEPParserTestCases.""" settings = frontend.get_default_settings(rst.Parser, pep.Reader) settings.report_level = 5 settings.halt_level = 5 settings.debug = package_unittest.debug class PEPParserTestSuite(ParserTestSuite): """A collection of PEPParserTestCases.""" test_case_class = PEPParserTestCase # Optional tests with 3rd party CommonMark parser # =============================================== # TODO: test with alternative CommonMark parsers? md_parser_name = 'recommonmark' # md_parser_name = 'pycmark' # md_parser_name = 'myst' md_skip_msg = f'Cannot test "{md_parser_name}". Parser not found.' try: md_parser_class = docutils.parsers.get_parser_class( md_parser_name) except ImportError: md_parser_class = None if md_parser_class and md_parser_name == 'recommonmark': import recommonmark if recommonmark.__version__ < '0.6.0': md_parser_class = None md_skip_msg = f'"{md_parser_name}" parser too old, skip tests' @unittest.skipUnless(md_parser_class, md_skip_msg) class RecommonmarkParserTestCase(ParserTestCase): """Test case for 3rd-party CommonMark parsers.""" if md_parser_class: parser = md_parser_class() settings = frontend.get_default_settings(md_parser_class) settings.report_level = 5 settings.halt_level = 5 settings.debug = package_unittest.debug class RecommonmarkParserTestSuite(ParserTestSuite): """A collection of RecommonmarkParserTestCases.""" test_case_class = RecommonmarkParserTestCase class GridTableParserTestCase(CustomTestCase): parser = tableparser.GridTableParser() def test_parse_table(self): self.parser.setup(StringList(string2lines(self.input), 'test data')) try: self.parser.find_head_body_sep() self.parser.parse_table() output = self.parser.cells except Exception as details: output = '%s: %s' % (details.__class__.__name__, details) self.compare_output(self.input, pformat(output) + '\n', pformat(self.expected) + '\n') def test_parse(self): try: output = self.parser.parse(StringList(string2lines(self.input), 'test data')) except Exception as details: output = '%s: %s' % (details.__class__.__name__, details) self.compare_output(self.input, pformat(output) + '\n', pformat(self.expected) + '\n') class GridTableParserTestSuite(CustomTestSuite): """ A collection of GridTableParserTestCases. A GridTableParserTestSuite instance manufactures GridTableParserTestCases, keeps track of them, and provides a shared test fixture (a-la setUp and tearDown). """ test_case_class = GridTableParserTestCase def generateTests(self, dict, dictname='totest'): """ Stock the suite with test cases generated from a test data dictionary. Each dictionary key (test type name) maps to a list of tests. Each test is a list: an input table, expected output from parse_table(), expected output from parse(), optional modifier. The optional fourth entry, a behavior modifier, can be 0 (temporarily disable this test) or 1 (run this test under the pdb debugger). Tests should be self-documenting and not require external comments. """ for name, cases in dict.items(): for casenum in range(len(cases)): case = cases[casenum] run_in_debugger = False if len(case) == 4: if case[-1]: run_in_debugger = True else: continue self.addTestCase(self.test_case_class, 'test_parse_table', input=case[0], expected=case[1], id='%s[%r][%s]' % (dictname, name, casenum), run_in_debugger=run_in_debugger) self.addTestCase(self.test_case_class, 'test_parse', input=case[0], expected=case[2], id='%s[%r][%s]' % (dictname, name, casenum), run_in_debugger=run_in_debugger) class SimpleTableParserTestCase(GridTableParserTestCase): parser = tableparser.SimpleTableParser() class SimpleTableParserTestSuite(CustomTestSuite): """ A collection of SimpleTableParserTestCases. """ test_case_class = SimpleTableParserTestCase def generateTests(self, dict, dictname='totest'): """ Stock the suite with test cases generated from a test data dictionary. Each dictionary key (test type name) maps to a list of tests. Each test is a list: an input table, expected output from parse(), optional modifier. The optional third entry, a behavior modifier, can be 0 (temporarily disable this test) or 1 (run this test under the pdb debugger). Tests should be self-documenting and not require external comments. """ for name, cases in dict.items(): for casenum in range(len(cases)): case = cases[casenum] run_in_debugger = False if len(case) == 3: if case[-1]: run_in_debugger = True else: continue self.addTestCase(self.test_case_class, 'test_parse', input=case[0], expected=case[1], id='%s[%r][%s]' % (dictname, name, casenum), run_in_debugger=run_in_debugger) class WriterPublishTestCase(CustomTestCase, docutils.SettingsSpec): """ Test case for publish. """ settings_default_overrides = {'_disable_config': True, 'strict_visitor': True} writer_name = '' # set in subclasses or constructor def __init__(self, *args, writer_name='', **kwargs): if writer_name: self.writer_name = writer_name super().__init__(*args, **kwargs) def test_publish(self): if self.run_in_debugger: pdb.set_trace() output = docutils.core.publish_string( source=self.input, reader_name='standalone', parser_name='restructuredtext', writer_name=self.writer_name, settings_spec=self, settings_overrides=self.suite_settings) self.compare_output(self.input, output, self.expected) class PublishTestSuite(CustomTestSuite): def __init__(self, writer_name, suite_settings=None): """ `writer_name` is the name of the writer to use. """ CustomTestSuite.__init__(self, suite_settings=suite_settings) self.test_class = WriterPublishTestCase self.writer_name = writer_name def generateTests(self, dict, dictname='totest'): for name, cases in dict.items(): for casenum in range(len(cases)): case = cases[casenum] run_in_debugger = False if len(case) == 3: if case[2]: run_in_debugger = True else: continue self.addTestCase( self.test_class, 'test_publish', input=case[0], expected=case[1], id='%s[%r][%s]' % (dictname, name, casenum), run_in_debugger=run_in_debugger, # Passed to constructor of self.test_class: writer_name=self.writer_name) class HtmlWriterPublishPartsTestCase(WriterPublishTestCase): """ Test case for HTML writer via the publish_parts interface. """ writer_name = 'html' settings_default_overrides = \ WriterPublishTestCase.settings_default_overrides.copy() settings_default_overrides['stylesheet'] = '' def test_publish(self): if self.run_in_debugger: pdb.set_trace() parts = docutils.core.publish_parts( source=self.input, reader_name='standalone', parser_name='restructuredtext', writer_name=self.writer_name, settings_spec=self, settings_overrides=self.suite_settings) output = self.format_output(parts) # interpolate standard variables: expected = self.expected % {'version': docutils.__version__} self.compare_output(self.input, output, expected) standard_content_type_template = ('\n') standard_generator_template = ( '\n') standard_html_meta_value = ( standard_content_type_template + standard_generator_template % docutils.__version__) standard_meta_value = standard_html_meta_value % 'utf-8' standard_html_prolog = ( '\n' '\n') def format_output(self, parts): """Minimize & standardize the output.""" # remove redundant parts & uninteresting parts: del parts['whole'] assert parts['body'] == parts['fragment'] del parts['body'] del parts['body_pre_docinfo'] del parts['body_prefix'] del parts['body_suffix'] del parts['head'] del parts['head_prefix'] del parts['encoding'] del parts['version'] # remove standard portions: parts['meta'] = parts['meta'].replace(self.standard_meta_value, '') parts['html_head'] = parts['html_head'].replace( self.standard_html_meta_value, '...') parts['html_prolog'] = parts['html_prolog'].replace( self.standard_html_prolog, '') output = [] for key in sorted(parts.keys()): if not parts[key]: continue output.append("%r: '''%s'''" % (key, parts[key])) if output[-1].endswith("\n'''"): output[-1] = output[-1][:-4] + "\\n'''" return '{' + ',\n '.join(output) + '}\n' class HtmlPublishPartsTestSuite(CustomTestSuite): testcase_class = HtmlWriterPublishPartsTestCase def generateTests(self, dict, dictname='totest'): for name, (settings_overrides, cases) in dict.items(): settings = self.suite_settings.copy() settings.update(settings_overrides) for casenum in range(len(cases)): case = cases[casenum] run_in_debugger = False if len(case) == 3: if case[2]: run_in_debugger = True else: continue self.addTestCase(self.testcase_class, 'test_publish', input=case[0], expected=case[1], id='%s[%r][%s]' % (dictname, name, casenum), run_in_debugger=run_in_debugger, suite_settings=settings) def exception_data(func, *args, **kwds): """ Execute `func(*args, **kwds)` and return the resulting exception, the exception arguments, and the formatted exception string. """ try: func(*args, **kwds) except Exception as detail: return (detail, detail.args, '%s: %s' % (detail.__class__.__name__, detail)) return None, [], "No exception" def _format_str(*args): r""" Return a tuple containing representations of all args. Same as map(repr, args) except that it returns multi-line representations for strings containing newlines, e.g.:: '''\ foo \n\ bar baz''' instead of:: 'foo \nbar\n\nbaz' This is a helper function for CustomTestCase. """ return_tuple = [] for i in args: r = repr(i) if isinstance(i, (str, bytes)) and '\n' in i: stripped = '' if isinstance(i, bytes) and r.startswith('b'): stripped = r[0] r = r[1:] # quote_char = "'" or '"' quote_char = r[0] assert quote_char in ("'", '"'), quote_char assert r[0] == r[-1] r = r[1:-1] r = (stripped + 3*quote_char + '\\\n' + re.sub(r'(?