#!/usr/bin/env python3 # $Id: test_publisher.py 10077 2025-04-09 08:55:54Z milde $ # Author: Martin Blais # Copyright: This module has been placed in the public domain. """ Test the `Publisher` facade and the ``publish_*`` convenience functions. """ import pickle from pathlib import Path import sys import unittest if __name__ == '__main__': # prepend the "docutils root" to the Python library path # so we import the local `docutils` package. sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import docutils import docutils.parsers.null from docutils import core, nodes, parsers, readers, writers from docutils.writers import html4css1, odf_odt, pseudoxml # DATA_ROOT is ./test/data/ from the docutils root DATA_ROOT = Path(__file__).parent / 'data' test_document = """\ Test Document ============= This is a test document with a broken reference: nonexistent_ """ pseudoxml_output = """\ Test Document <paragraph> This is a test document with a broken reference: \n\ <problematic ids="problematic-1" refid="system-message-1"> nonexistent_ <section classes="system-messages"> <title> Docutils System Messages <system_message backrefs="problematic-1" ids="system-message-1" level="3" line="4" source="<string>" type="ERROR"> <paragraph> Unknown target name: "nonexistent". """ exposed_pseudoxml_output = """\ <document ids="test-document" internal:refnames="{'nonexistent': [<reference: <#text: 'nonexistent'>>]}" names="test\\ document" source="<string>" title="Test Document"> <title> Test Document <paragraph> This is a test document with a broken reference: \n\ <problematic ids="problematic-1" refid="system-message-1"> nonexistent_ <section classes="system-messages"> <title> Docutils System Messages <system_message backrefs="problematic-1" ids="system-message-1" level="3" line="4" source="<string>" type="ERROR"> <paragraph> Unknown target name: "nonexistent". """ class PublisherTests(unittest.TestCase): def test__init__(self): reader = readers.standalone.Reader() parser = parsers.null.Parser() writer = writers.null.Writer() # arguments may be component instances ... publisher = core.Publisher(reader, parser, writer) self.assertEqual(publisher.reader, reader) self.assertEqual(publisher.parser, parser) self.assertEqual(publisher.writer, writer) # ... or names publisher = core.Publisher('standalone', parser, writer) self.assertTrue(isinstance(publisher.reader, readers.standalone.Reader)) self.assertEqual(publisher.parser, parser) self.assertEqual(publisher.writer, writer) publisher = core.Publisher(reader, 'rst', writer) self.assertEqual(publisher.reader, reader) self.assertTrue(isinstance(publisher.parser, parsers.rst.Parser)) self.assertEqual(publisher.writer, writer) publisher = core.Publisher(reader, parser, 'latex') self.assertEqual(publisher.reader, reader) self.assertEqual(publisher.parser, parser) self.assertTrue(isinstance(publisher.writer, writers.latex2e.Writer)) def test_set_reader(self): publisher = core.Publisher(parser='null') parser = parsers.null.Parser() # "parser" argument can be an instance or name publisher.set_reader('standalone', parser='rst') self.assertTrue(isinstance(publisher.parser, parsers.rst.Parser)) # synchronize parser attributes of publisher and reader: self.assertEqual(publisher.reader.parser, publisher.parser) # the "parser_name" argument is deprecated; with self.assertWarnsRegex(PendingDeprecationWarning, 'Argument "parser_name" will be removed'): publisher.set_reader('standalone', parser=None, parser_name='rst') self.assertTrue(isinstance(publisher.parser, parsers.rst.Parser)) self.assertEqual(publisher.reader.parser, publisher.parser) # "parser" takes precedence with self.assertWarns(PendingDeprecationWarning): publisher.set_reader('standalone', parser, parser_name='rst') self.assertEqual(publisher.parser, parser) self.assertEqual(publisher.reader.parser, publisher.parser) # if there is no other parser specified, use self.parser publisher.set_reader('standalone') self.assertTrue(isinstance(publisher.parser, parsers.null.Parser)) self.assertEqual(publisher.reader.parser, publisher.parser) def test_set_components(self): publisher = core.Publisher() reader = readers.standalone.Reader() parser = parsers.null.Parser() writer = writers.null.Writer() # set components from names with self.assertWarnsRegex(PendingDeprecationWarning, 'set_components.* will be removed'): publisher.set_components('pep', 'rst', 'odt') self.assertTrue(isinstance(publisher.reader, readers.pep.Reader)) self.assertTrue(isinstance(publisher.parser, parsers.rst.Parser)) self.assertTrue(isinstance(publisher.writer, writers.odf_odt.Writer)) # but don't overwrite registered component instances publisher = core.Publisher(reader, parser, writer) with self.assertWarns(PendingDeprecationWarning): publisher.set_components('standalone', 'xml', 'odt') self.assertEqual(publisher.reader, reader) self.assertEqual(publisher.parser, parser) self.assertEqual(publisher.writer, writer) def test_set_destination(self): # Exit if `_destination` and `output` settings conflict. publisher = core.Publisher() publisher.get_settings(output_path='out_name', _destination='other') with self.assertRaises(SystemExit): publisher.set_destination() # no conflict if both have same value: publisher.settings._destination = 'out_name' publisher.set_destination() # no conflict if both are overridden: publisher.set_destination(destination_path='winning_dest') # "output_path" and legacy settings are set to `destination_path`: self.assertEqual(publisher.settings.output_path, 'winning_dest') self.assertEqual(publisher.settings.output, 'winning_dest') self.assertEqual(publisher.settings._destination, 'winning_dest') class ConvenienceFunctionTests(unittest.TestCase): maxDiff = None settings = {'_disable_config': True, 'datestamp': False} def test_publish_cmdline(self): # the "*_name" arguments will be removed with self.assertWarns(PendingDeprecationWarning): core.publish_cmdline(writer_name='null', argv=[(DATA_ROOT/'include.rst').as_posix()], settings_overrides={'traceback': True}) def test_input_error_handling(self): # core.publish_cmdline(argv=['nonexisting/path']) # exits with a short message, if `traceback` is False, # pass IOErrors to calling application if `traceback` is True: with self.assertRaises(IOError): core.publish_cmdline(argv=['nonexisting/path'], settings_overrides={'traceback': True}) def test_output_error_handling(self): # pass IOErrors to calling application if `traceback` is True with self.assertRaises(docutils.io.OutputError): core.publish_cmdline(argv=[(DATA_ROOT/'include.rst').as_posix(), 'nonexisting/path'], settings_overrides={'traceback': True}) def test_destination_output_conflict(self): # Exit if positional argument and --output option conflict. settings = {'output_path': 'out_name'} with self.assertRaises(SystemExit): core.publish_cmdline(argv=['-', 'dest_name'], settings_overrides=settings) def test_publish_string_input_encoding(self): """Test handling of encoded input.""" # Transparently decode `bytes` source (with "input_encoding" setting) # default: utf-8 # Output is encoded according to "output_encoding" setting. settings = self.settings | {'input_encoding': 'utf-16', 'output_encoding': 'unicode'} source = 'test → me' expected = ('<document source="<string>">\n' ' <paragraph>\n' ' test → me\n') output = core.publish_string(source.encode('utf-16'), settings_overrides=settings) self.assertEqual(expected, output) # encoding declaration in source (used if input_encoding is None) # input encoding detection will be removed in Docutils 1.0 source = '.. encoding: latin1\n\nGrüße' settings['input_encoding'] = None with self.assertWarnsRegex(DeprecationWarning, 'auto-detection'): output = core.publish_string(source.encode('latin1'), settings_overrides=settings) self.assertTrue(output.endswith('Grüße\n')) def test_publish_string_output_encoding(self): settings = self.settings | {'output_encoding': 'latin1'} settings['output_encoding_error_handler'] = 'replace' source = 'Grüß → dich' expected = ('<document source="<string>">\n' ' <paragraph>\n' ' Grüß → dich\n') # encode output, return `bytes` output = bytes(core.publish_string(source, settings_overrides=settings)) self.assertEqual(expected.encode('latin1', 'replace'), output) def test_publish_string_output_encoding_odt(self): """The ODT writer generates a zip archive, not a `str`. TODO: return `str` with document as "flat XML" (.fodt). """ settings = self.settings | {'output_encoding': 'unicode', 'warning_stream': ''} with self.assertRaisesRegex(docutils.utils.SystemMessage, 'The ODT writer returns `bytes` '): core.publish_string('test', writer=odf_odt.Writer(), settings_overrides=settings) def test_publish_string_deprecation_warning(self): """The "*_name" arguments are deprecated.""" source = 'test → me' with self.assertWarns(PendingDeprecationWarning): output = core.publish_string(source, writer_name='xml') # ... but should still set the corresponding component: self.assertTrue(output.decode('utf-8').startswith( '<?xml version="1.0" encoding="utf-8"?>')) class PublishDoctreeTestCase(unittest.TestCase, docutils.SettingsSpec): settings_default_overrides = { '_disable_config': True, 'warning_stream': docutils.io.NullOutput(), 'output_encoding': 'unicode'} def test_publish_doctree(self): # Test `publish_doctree` and `publish_from_doctree`. # Produce the document tree. with self.assertWarns(PendingDeprecationWarning): doctree = core.publish_doctree( source=test_document, parser_name='restructuredtext', settings_spec=self, settings_overrides={'expose_internals': ['refnames', 'do_not_expose'], 'report_level': 5}) self.assertTrue(isinstance(doctree, nodes.document)) # Confirm that transforms have been applied (in this case, the # DocTitle transform): self.assertTrue(isinstance(doctree[0], nodes.title)) self.assertTrue(isinstance(doctree[1], nodes.paragraph)) # Confirm that the Messages transform has not yet been applied: self.assertEqual(2, len(doctree)) # The `do_not_expose` attribute may not show up in the # pseudoxml output because the expose_internals transform may # not be applied twice. doctree.do_not_expose = 'test' # Write out the document: output = core.publish_from_doctree( doctree, writer=pseudoxml.Writer(), settings_spec=self, settings_overrides={'expose_internals': ['refnames', 'do_not_expose'], 'report_level': 1, 'output_encoding': 'unicode'}) self.assertEqual(exposed_pseudoxml_output, output) # Test publishing parts using document as the source. parts = core.publish_parts( reader='doctree', source_class=docutils.io.DocTreeInput, source=doctree, source_path='test', writer=html4css1.Writer(), settings_spec=self) self.assertTrue(isinstance(parts, dict)) def test_publish_pickle(self): # Test publishing a document tree with pickling and unpickling. # Produce the document tree. doctree = core.publish_doctree( source=test_document, settings_spec=self) self.assertTrue(isinstance(doctree, nodes.document)) # Pickle the document. Note: if this fails, some unpickleable # reference has been added somewhere within the document tree. # If so, you need to fix that. # # Note: Please do not remove this test, this is an important # requirement, applications will be built on the assumption # that we can pickle the document. # Remove the reporter and the transformer before pickling. doctree.reporter = None doctree.transformer = None doctree_pickled = pickle.dumps(doctree) self.assertTrue(isinstance(doctree_pickled, bytes)) del doctree # Unpickle the document. doctree_zombie = pickle.loads(doctree_pickled) self.assertTrue(isinstance(doctree_zombie, nodes.document)) # Write out the document: with self.assertWarnsRegex(PendingDeprecationWarning, 'Argument "writer_name" will be removed '): output = core.publish_from_doctree(doctree_zombie, writer_name='pseudoxml', settings_spec=self) self.assertEqual(pseudoxml_output, output) if __name__ == '__main__': unittest.main()