SF.net SVN: docutils:[10202 ] trunk/docutils

milde--- via Docutils-checkins <[email protected]> Fri, 15 Aug 2025 17:37:58 +0000
Newsgroups gmane.text.docutils.cvs
Message-ID <[email protected]>
Revision: 10202
          http://sourceforge.net/p/docutils/code/10202
Author:   milde
Date:     2025-08-15 17:37:57 +0000 (Fri, 15 Aug 2025)
Log Message:
-----------
Fixes for the section level determination.

Simplify the fix in [r10200].
  Don't add elements without valid parent to the list returned by
  `nodes.Element.section_hierarchy()` instead of removing them later.

  Remove the check/report for `new_parent is None` (we don't add
  elements with .parent None to the `parent_sections` list any more).

Don't add a new section title style to the list of established title styles
if it is inconsistent.

Annotate and document arguments of `RSTState.nested_parse()`.

Modified Paths:
--------------
    trunk/docutils/docutils/nodes.py
    trunk/docutils/docutils/parsers/rst/states.py
    trunk/docutils/test/test_parsers/test_rst/test_misc.py
    trunk/docutils/test/test_parsers/test_rst/test_section_headers.py

Modified: trunk/docutils/docutils/nodes.py
===================================================================
--- trunk/docutils/docutils/nodes.py	2025-08-15 17:37:47 UTC (rev 10201)
+++ trunk/docutils/docutils/nodes.py	2025-08-15 17:37:57 UTC (rev 10202)
@@ -820,18 +820,21 @@
     def section_hierarchy(self) -> list[section]:
         """Return the element's section hierarchy.
 
-        Return a list of all <section> elements containing `self`
-        (including `self` if it is a <section>).
+        Return a list of all <section> elements that contain `self`
+        (including `self` if it is a <section>) and have a parent node.
 
         List item ``[i]`` is the parent <section> of level i+1
         (1: section, 2: subsection, 3: subsubsection, ...).
         The length of the list is the element's section level.
 
+        See `docutils.parsers.rst.states.RSTState.check_subsection()`
+        for a usage example.
+
         Provisional. May be changed or removed without warning.
         """
         sections = []
         node = self
-        while node is not None:
+        while node.parent is not None:
             if isinstance(node, section):
                 sections.append(node)
             node = node.parent

Modified: trunk/docutils/docutils/parsers/rst/states.py
===================================================================
--- trunk/docutils/docutils/parsers/rst/states.py	2025-08-15 17:37:47 UTC (rev 10201)
+++ trunk/docutils/docutils/parsers/rst/states.py	2025-08-15 17:37:57 UTC (rev 10202)
@@ -121,7 +121,11 @@
 from docutils.utils._roman_numerals import (InvalidRomanNumeralError,
                                             RomanNumeral)
 
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    from docutils.statemachine import StringList
 
+
 class MarkupError(DataError): pass
 class UnknownInterpretedRoleError(DataError): pass
 class InterpretedRoleNotImplementedError(DataError): pass
@@ -250,11 +254,37 @@
         """Called at beginning of file."""
         return [], []
 
-    def nested_parse(self, block, input_offset, node, match_titles=False,
-                     state_machine_class=None, state_machine_kwargs=None):
+    def nested_parse(self,
+                     block: StringList,
+                     input_offset: int,
+                     node: nodes.Element,
+                     match_titles: bool = False,
+                     state_machine_class: StateMachineWS|None = None,
+                     state_machine_kwargs: dict|None = None
+                     ) -> int:
         """
-        Create a new StateMachine rooted at `node` and run it over the input
-        `block`.
+        Parse the input `block` with a nested state-machine rooted at `node`.
+
+        :block:
+            reStructuredText source extract.
+        :input_offset:
+            Line number at start of the block.
+        :node:
+            Root node. Generated nodes will be appended to this node
+            (unless a new section with lower level is encountered).
+        :match_titles:
+            Allow section titles?
+            If True, `node` should be attached to the document
+            so that section levels can be computed correctly
+            and moving up in the section hierarchy works.
+        :state_machine_class:
+            Default: `NestedStateMachine`.
+        :state_machine_kwargs:
+            Keyword arguments for the state-machine instantiation.
+            Default: `self.nested_sm_kwargs`.
+
+        Create a new state-machine instance if required.
+        Return new offset.
         """
         use_default = 0
         if state_machine_class is None:
@@ -263,8 +293,6 @@
         if state_machine_kwargs is None:
             state_machine_kwargs = self.nested_sm_kwargs
             use_default += 1
-        block_length = len(block)
-
         state_machine = None
         if use_default == 2:
             try:
@@ -274,8 +302,11 @@
         if not state_machine:
             state_machine = state_machine_class(debug=self.debug,
                                                 **state_machine_kwargs)
+        # run the statemachine and populate `node`:
+        block_length = len(block)
         state_machine.run(block, input_offset, memo=self.memo,
                           node=node, match_titles=match_titles)
+        # clean up
         if use_default == 2:
             self.nested_sm_cache.append(state_machine)
         else:
@@ -332,12 +363,6 @@
         """
         title_styles = self.memo.title_styles
         parent_sections = self.parent.section_hierarchy()
-        # Adding a new <section> at level "i" is done by appending to
-        # ``parent_sections[i-1].parent``.
-        # However, in nested parsing the root `node` may be a <section>.
-        # Then ``parent_sections[0]`` has no parent and must be discarded:
-        if parent_sections and parent_sections[0].parent is None:
-            parent_sections.pop(0)
         # current section level: (0 root, 1 section, 2 subsection, ...)
         oldlevel = len(parent_sections)
         # new section level:
@@ -344,8 +369,7 @@
         try:  # check for existing title style
             newlevel = title_styles.index(style) + 1
         except ValueError:  # new title style
-            title_styles.append(style)
-            newlevel = len(title_styles)
+            newlevel = len(title_styles) + 1
         # The new level must not be deeper than an immediate child
         # of the current level:
         if newlevel > oldlevel + 1:
@@ -358,23 +382,12 @@
                 line=lineno)
             return False
         # Update parent state:
+        if newlevel > len(title_styles):
+            title_styles.append(style)
         self.memo.section_level = newlevel
         if newlevel <= oldlevel:
             # new section is sibling or higher up in the section hierarchy
-            new_parent = parent_sections[newlevel-1].parent
-            if new_parent is None:
-                styles = ' '.join('/'.join(style) for style in title_styles)
-                self.parent += self.reporter.error(
-                    f'Cannot skip from level {oldlevel} to {newlevel}.'
-                    ' Current element has only {len(self.parent_sections)}'
-                    ' parent sections.'
-                    ' (Mismatch of `memo.section_styles`,'
-                    ' and the root node of a nested parser?)',
-                    nodes.literal_block('', source),
-                    nodes.paragraph('', f'Established title styles: {styles}'),
-                    line=lineno)
-                return False
-            self.parent = new_parent
+            self.parent = parent_sections[newlevel-1].parent
         return True
 
     def title_inconsistent(self, sourcetext, lineno):

Modified: trunk/docutils/test/test_parsers/test_rst/test_misc.py
===================================================================
--- trunk/docutils/test/test_parsers/test_rst/test_misc.py	2025-08-15 17:37:47 UTC (rev 10201)
+++ trunk/docutils/test/test_parsers/test_rst/test_misc.py	2025-08-15 17:37:57 UTC (rev 10202)
@@ -9,6 +9,7 @@
 
 from pathlib import Path
 import sys
+import types
 import unittest
 
 if __name__ == '__main__':
@@ -15,8 +16,9 @@
     # prepend the local "docutils root" to the Python library path
     sys.path.insert(0, Path(__file__).resolve().parents[2].as_posix())
 
-from docutils import frontend, utils
+from docutils import frontend, nodes, statemachine, utils
 import docutils.parsers.rst
+from docutils.parsers.rst import states
 
 
 class RstParserTests(unittest.TestCase):
@@ -30,5 +32,126 @@
             parser.parse(b'hol', document)
 
 
+class RSTStateTests(unittest.TestCase):
+
+    # state machine
+    machine = states.RSTStateMachine(state_classes=states.state_classes,
+                                     initial_state='Body')
+    # "generic" state
+    state = states.RSTState(machine)
+
+    def title_markup(self, text, adornment='-'):
+        underline = adornment * len(text)
+        return statemachine.StringList([text, underline], 'section block')
+
+    def setUp(self):
+        # state machine runtime initialization (cf. RSTStateMachine.run())
+        # Only for test:
+        #    don't use this low-level approach in production code!
+
+        # empty <document> and settings:
+        settings = frontend.get_default_settings(docutils.parsers.rst.Parser)
+        settings.halt_level = 2
+        settings.warning_stream = ''
+        document = self.document = utils.new_document('test data', settings)
+        # language module (localized directive and role names)
+        # self.machine.language = languages.get_language(
+        #                             document.settings.language_code)
+        # self.machine.match_titles = True  # support sections
+        # "memo": Container for document-wide auxiliary data
+        inliner = states.Inliner()  # rST parser for inline markup
+        inliner.init_customizations(document.settings)
+        self.machine.memo = types.SimpleNamespace(document=document,
+                                                  reporter=document.reporter,
+                                                  language='en',
+                                                  title_styles=[],
+                                                  inliner=inliner)
+        # self.machine.document = document
+        # self.machine.reporter = document.reporter
+        self.machine.node = document
+        # initialize `state` object
+        self.state.runtime_init()
+
+    def test_nested_parse(self):
+        # parse a text block in a nested parser
+        text = statemachine.StringList(['test input'], 'nested block')
+        tip = nodes.tip('')  # base `node`
+
+        # plain text -> attach <paragraph> to `node`:
+        self.state.nested_parse(text, input_offset=0, node=tip)
+        self.assertEqual('<tip><paragraph>test input</paragraph></tip>',
+                         str(tip))
+
+        # by default, section titles are not supported:
+        title = self.title_markup('top', '-')
+        with self.assertRaisesRegex(utils.SystemMessage,
+                                    'Unexpected section title.'):
+            self.state.nested_parse(title, input_offset=0, node=tip)
+
+    # Nested parsing with section markup is supported with
+    # ``match_titles=True`` but not used in Docutils.
+    # (However, Sphinx "autodoc" and some contributed extensions use it.)
+    def test_nested_parse_with_sections_base_attached(self):
+        # If the base `node` is attached to the `document`,
+        # the document-wide section style hierarchy is used.
+
+        # base`node` is a <section>
+        section = nodes.section('')
+        self.document += section  # attach to document
+        self.machine.memo.title_styles.append('-')  # register title style
+        # parse level-2 section title
+        title = self.title_markup('sub', '~')
+        # -> append new <section> to the parsers base `node`
+        self.state.nested_parse(title, 0, node=section, match_titles=True)
+        self.assertEqual('<document source="test data">\n'
+                         '    <section>\n'
+                         '        <section ids="sub" names="sub">\n'
+                         '            <title>\n'
+                         '                sub\n',
+                         str(self.document.pformat()))
+        self.assertEqual(['-', '~'], self.machine.memo.title_styles)
+
+        # parse top-level section title
+        title = self.title_markup('top', '-')
+        # -> move 1 level up and attach <section> to document
+        self.state.nested_parse(title, 0, node=section, match_titles=True)
+        self.assertEqual('<document source="test data">\n'
+                         '    <section>\n'
+                         '        <section ids="sub" names="sub">\n'
+                         '            <title>\n'
+                         '                sub\n'
+                         '    <section ids="top" names="top">\n'
+                         '        <title>\n'
+                         '            top\n',
+                         str(self.document.pformat()))
+
+        # base`node` is a <paragraph>
+        paragraph = nodes.paragraph('', 'base node')
+        section += paragraph  # attach (indirectly)
+        # parse top-level section title
+        title = self.title_markup('top 2', '-')
+        # -> move 1 level up and attach <section> to document
+        self.state.nested_parse(title, 0, node=paragraph, match_titles=True)
+        self.assertEqual('<section ids="top-2" names="top\\ 2">\n'
+                         '    <title>\n'
+                         '        top 2\n',
+                         self.document[-1].pformat())
+
+        # new (2nd-level) section title
+        # TODO: don't append <section> to <paragraph>!
+        title = self.title_markup('sub 2', '~')
+        self.state.nested_parse(title, 0, node=paragraph, match_titles=True)
+        self.assertEqual('<section>\n'
+                         '    <section ids="sub" names="sub">\n'
+                         '        <title>\n'
+                         '            sub\n'
+                         '    <paragraph>\n'
+                         '        base node\n'
+                         '        <section ids="sub-2" names="sub\\ 2">\n'
+                         '            <title>\n'
+                         '                sub 2\n',
+                         section.pformat())
+
+
 if __name__ == '__main__':
     unittest.main()

Modified: trunk/docutils/test/test_parsers/test_rst/test_section_headers.py
===================================================================
--- trunk/docutils/test/test_parsers/test_rst/test_section_headers.py	2025-08-15 17:37:47 UTC (rev 10201)
+++ trunk/docutils/test/test_parsers/test_rst/test_section_headers.py	2025-08-15 17:37:57 UTC (rev 10202)
@@ -502,7 +502,7 @@
                 Title 4
                 ```````
             <paragraph>
-                Established title styles: = - `
+                Established title styles: = -
         <paragraph>
             Paragraph 4.
 """],
@@ -556,7 +556,7 @@
                 Title 4
                 ```````
             <paragraph>
-                Established title styles: =/= -/- `/`
+                Established title styles: =/= -/-
         <paragraph>
             Paragraph 4.
 """],

This was sent by the SourceForge.net collaborative development platform, the world's largest Open Source development site.