jenkins-bot has submitted this change. ( 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1176691?usp=email )

Change subject: IMPR: Improvements for textlib.Content class
......................................................................

IMPR: Improvements for textlib.Content class

- Add textlib.SectionList to hold Content.sections as a list but
  provide index and count method and in operator for the Section.heading
- use in operator within BasePage.get()
- update tests

Bug: T401464
Change-Id: Iaa23490a74b1033b0f7194d81f8df9bc3d4afebe
---
M pywikibot/cosmetic_changes.py
M pywikibot/page/_basepage.py
M pywikibot/textlib.py
M tests/textlib_tests.py
4 files changed, 157 insertions(+), 6 deletions(-)

Approvals:
  jenkins-bot: Verified
  Xqt: Looks good to me, approved
  Matěj Suchánek: Looks good to me, approved




diff --git a/pywikibot/cosmetic_changes.py b/pywikibot/cosmetic_changes.py
index ed1380b..f97c7a7 100644
--- a/pywikibot/cosmetic_changes.py
+++ b/pywikibot/cosmetic_changes.py
@@ -735,7 +735,7 @@
             return text

         # iterate stripped sections and create a new page body
-        new_body: list[textlib.Section] = []
+        new_body: textlib.SectionList[textlib.Section] = []
         for i, strip_section in enumerate(strip_sections):
             current_dep = sections[i].level
             try:
diff --git a/pywikibot/page/_basepage.py b/pywikibot/page/_basepage.py
index 6e3adfb..1e7ca3f 100644
--- a/pywikibot/page/_basepage.py
+++ b/pywikibot/page/_basepage.py
@@ -391,8 +391,7 @@
         page_section = self.section()
         if page_section:
             content = textlib.extract_sections(text, self.site)
-            headings = {section.heading for section in content.sections}
-            if page_section not in headings:
+            if page_section not in content.sections:
                 raise SectionError(f'{page_section!r} is not a valid section '
                                    f'of {self.title(with_section=False)}')

diff --git a/pywikibot/textlib.py b/pywikibot/textlib.py
index 4937bd0..da5187f 100644
--- a/pywikibot/textlib.py
+++ b/pywikibot/textlib.py
@@ -8,6 +8,7 @@

 import itertools
 import re
+import sys
 from collections import OrderedDict
 from collections.abc import Sequence
 from contextlib import closing, suppress
@@ -1119,6 +1120,101 @@
         return self.title[level:-level].strip()


+class SectionList(list):
+
+    """List of :class:`Section` objects with heading/level-aware index().
+
+    Introduced for handling lists of sections with custom lookup by
+    :attr:`Section.heading` and :attr:`level<Section.level>`.
+
+    .. versionadded:: 10.4
+    """
+
+    def __contains__(self, value: object) -> bool:
+        """Check if a section matching the given value exists.
+
+        :param value: The section heading string, a (heading, level) tuple,
+            or a :class:`Section` instance to search for.
+        :return: ``True`` if a matching section exists, ``False`` otherwise.
+        """
+        with suppress(ValueError):
+            self.index(value)
+            return True
+
+        return False
+
+    def count(self, value: str | tuple[str, int] | Section, /) -> int:
+        """Count the number of sections matching the given value.
+
+        :param value: The section heading string, a (heading, level) tuple,
+            or a :class:`Section` instance to search for.
+        :return: The number of matching sections.
+        """
+        if isinstance(value, Section):
+            return super().count(value)
+
+        if isinstance(value, tuple) and len(value) == 2:
+            heading, level = value
+            return sum(1 for sec in self
+                       if sec.heading == heading and sec.level == level)
+
+        if isinstance(value, str):
+            return sum(1 for sec in self if sec.heading == value)
+
+        return super().count(value)
+
+    def index(
+        self,
+        value: str | tuple[str, int] | Section,
+        start: int = 0,
+        stop: int = sys.maxsize,
+        /,
+    ) -> int:
+        """Return the index of a matching section.
+
+        Works like ``list.index(value, start, stop)`` but also allows:
+
+        - *value* as a string → match by :attr:`Section.heading` (any level)
+        - *value* as a ``(heading, level)`` tuple → match both
+          :attr:`heading<Section.heading>` and :attr:`level<Section.level>`
+        - *value* as a ``Section`` object → normal list.index() behavior
+
+        :param value: The item to search for. May be:
+            - ``str`` — search by section heading.
+            - ``tuple[str, int]`` — search by heading and section level.
+            - :class:`Section` — search for an exact section object.
+        :param start: Index to start searching from (inclusive).
+        :param stop: Index to stop searching at (exclusive).
+        :return: The integer index of the matching section.
+        :raises ValueError: If no matching section is found.
+        """
+        # Normalize negative indices
+        n = len(self)
+        start = max(0, n + start) if start < 0 else start
+        stop = max(0, n + stop) if stop < 0 else stop
+
+        if isinstance(value, Section):
+            return super().index(value, start, stop)
+
+        if isinstance(value, tuple) and len(value) == 2:
+            heading, level = value
+            for i, sec in enumerate(self[start:stop], start):
+                if sec.heading == heading and sec.level == level:
+                    return i
+
+            raise ValueError(
+                f'{value!r} not found in Section headings/levels')
+
+        if isinstance(value, str):
+            for i, sec in enumerate(self[start:stop], start):
+                if sec.heading == value:
+                    return i
+
+            raise ValueError(f'{value!r} not found in Section headings')
+
+        return super().index(value, start, stop)
+
+
 class Content(NamedTuple):

     """A namedtuple as result of :func:`extract_sections` holding page content.
@@ -1128,7 +1224,7 @@
     """

     header: str  #: the page header
-    sections: list[Section]  #: the page sections
+    sections: SectionList[Section]  #: the page sections
     footer: str  #: the page footer

     @property
@@ -1156,7 +1252,7 @@

 def _extract_sections(text: str, headings) -> list[Section]:
     """Return a list of :class:`Section` objects."""
-    sections = []
+    sections = SectionList()
     if headings:
         # Assign them their contents
         for heading, next_heading in pairwise(headings):
@@ -1217,6 +1313,16 @@
     '== History of this =='
     >>> result.sections[1].content.strip()
     'Enter "import this" for usage...'
+    >>> 'Details' in result.sections
+    True
+    >>> ('Details', 2) in result.sections
+    False
+    >>> result.sections.index('Details')
+    2
+    >>> result.sections.index(('Details', 2))
+    Traceback (most recent call last):
+    ...
+    ValueError: ('Details', 2) not found in Section headings/levels
     >>> result.sections[2].heading
     'Details'
     >>> result.sections[2].level
@@ -1232,6 +1338,9 @@
     .. versionchanged:: 8.2
        The :class:`Content` and :class:`Section` class have additional
        properties.
+    .. versionchanged:: 10.4
+       Added custom ``index()``, ``count()`` and ``in`` operator support
+       for :attr:`Content.sections`.

     :return: The parsed namedtuple.
     """  # noqa: D300, D301
diff --git a/tests/textlib_tests.py b/tests/textlib_tests.py
index f860109..4f4ab28 100755
--- a/tests/textlib_tests.py
+++ b/tests/textlib_tests.py
@@ -1542,11 +1542,16 @@
         self.assertEqual(result.footer, footer)
         self.assertEqual(result.title, title)
         self.assertEqual(result, (header, sections, footer))
-        for section in result.sections:
+        for i, section in enumerate(result.sections):
             self.assertIsInstance(section, tuple)
             self.assertLength(section, 2)
             self.assertIsInstance(section.level, int)
             self.assertEqual(section.title.count('=') // 2, section.level)
+            self.assertIn(section.heading, result.sections)
+            count = result.sections.count(section.heading)
+            self.assertGreaterEqual(count, 1)
+            if count == 1:
+                self.assertEqual(result.sections.index(section.heading), i)

     def test_no_sections_no_footer(self) -> None:
         """Test for text having no sections or footer."""
@@ -1566,6 +1571,7 @@
                 '==title==\n'
                 'content')
         result = extract_sections(text, self.site)
+        self.assertEqual(result.sections.index('title'), 0)
         self._extract_sections_tests(
             result, 'text\n\n', [('==title==', '\ncontent')])

@@ -1601,6 +1607,11 @@
                 '==title 2==\n'
                 'content')
         result = extract_sections(text, self.site)
+        self.assertEqual(result.sections.index('title'), 0)
+        self.assertEqual(result.sections.index(('title', 4)), 0)
+        with self.assertRaisesRegex(ValueError,
+                                    r"\('title', 2\) not found in Section"):
+            result.sections.index(('title', 2))
         self._extract_sections_tests(
             result,
             'text\n\n',
@@ -1676,6 +1687,38 @@
             title='Pywikibot'
         )

+    def test_index(self) -> None:
+        """Test index behaviour of SectionList."""
+        text = """
+= Intro =
+== History ==
+== Usage ==
+=== Details ===
+= References =
+"""
+        result = extract_sections(text, self.site)
+        self._extract_sections_tests(result, '\n', [
+            ('= Intro =', '\n'),
+            ('== History ==', '\n'),
+            ('== Usage ==', '\n'),
+            ('=== Details ===', '\n'),
+            ('= References =', '\n'),
+        ])
+        sections = result.sections
+        self.assertIsInstance(sections, textlib.SectionList)
+        self.assertEqual(sections.index('Details'), 3)
+        self.assertEqual(sections.index('Details', 3), 3)
+        self.assertEqual(sections.index(sections[2]), 2)
+        self.assertEqual(sections.index('Intro', -10, 3), 0)
+        header = 'Details', 2
+        pattern = re.escape(f'{header!r} not found in Section headings/levels')
+        with self.assertRaisesRegex(ValueError, pattern):
+            sections.index(header)
+        header = 'Unknown'
+        with self.assertRaisesRegex(
+                ValueError, f'{header!r} not found in Section heading'):
+            sections.index(header)
+

 if __name__ == '__main__':
     with suppress(SystemExit):

--
To view, visit 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1176691?usp=email
To unsubscribe, or for help writing mail filters, visit 
https://gerrit.wikimedia.org/r/settings?usp=email

Gerrit-MessageType: merged
Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Change-Id: Iaa23490a74b1033b0f7194d81f8df9bc3d4afebe
Gerrit-Change-Number: 1176691
Gerrit-PatchSet: 8
Gerrit-Owner: Xqt <[email protected]>
Gerrit-Reviewer: Matěj Suchánek <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to