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]