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

Change subject: textlib: Reduce replace_links complexity
......................................................................

textlib: Reduce replace_links complexity

Link parsing, callback coordination, and rendering made replace_links
hard to maintain safely. Separate these responsibilities into private
helpers while preserving public behavior.

Add tests for callback context and skipped or invalid inputs.

Bug: T434503
Change-Id: Idb2bff6884d6cefb448d77b3103554a01c872c98
---
M pywikibot/textlib.py
M tests/textlib_tests.py
2 files changed, 266 insertions(+), 170 deletions(-)

Approvals:
  jenkins-bot: Verified
  Xqt: Looks good to me, approved




diff --git a/pywikibot/textlib.py b/pywikibot/textlib.py
index 4782bc8..0e33c13 100644
--- a/pywikibot/textlib.py
+++ b/pywikibot/textlib.py
@@ -830,6 +830,209 @@
     return marker


+class _LinkMatch(NamedTuple):
+
+    """A parsed wikilink and its location in the source text."""
+
+    link: pywikibot.Link
+    groups: dict[str, str | None]
+    start: int
+    end: int
+
+
+def _validate_link_replacement(replacement) -> None:
+    """Validate a Page or Link replacement."""
+    if not isinstance(replacement, (pywikibot.Page, pywikibot.Link)):
+        raise ValueError('The replacement must be None, False, a'
+                         ' sequence, a Link or a str but is '
+                         f'"{type(replacement)}"')
+
+
+def _normalize_link_replacer(replace, site: pywikibot.site.BaseSite):
+    """Return a callback for a callable or sequence replacement."""
+    if not isinstance(replace, Sequence):
+        return replace
+
+    if len(replace) != 2:
+        raise ValueError('When used as a sequence, the "replace" '
+                         'argument must contain exactly 2 items.')
+
+    old_link, new_link = replace
+    if isinstance(old_link, pywikibot.Page):
+        old_link = old_link._link
+    elif isinstance(old_link, str):
+        old_link = pywikibot.Link(old_link, site)
+
+    if not isinstance(old_link, pywikibot.Link):
+        raise ValueError(
+            'The original value must be either str, Link or Page but is '
+            f'"{type(old_link)}"')
+
+    if isinstance(new_link, str):
+        new_link = pywikibot.Page(site, new_link)
+
+    def replace_callable(link, text, groups, rng):
+        """Return the replacement when the old link matches."""
+        if old_link == link:
+            return new_link
+        return None
+
+    return replace_callable
+
+
+def _find_next_link(
+    text: str,
+    start: int,
+    site: pywikibot.site.BaseSite,
+    link_pattern: re.Pattern[str],
+    extended_label_pattern: re.Pattern[str],
+) -> _LinkMatch | None:
+    """Return the next valid wikilink at or after *start*."""
+    while match := link_pattern.search(text, pos=start):
+        title = match['title'].strip()
+
+        if not title or (site.isInterwikiLink(title)
+                         and not title.startswith(':')):
+            start = match.end()
+            continue
+
+        groups = match.groupdict()
+        end = match.end()
+        if groups['label'] and '[[' in groups['label']:
+            # TODO: Work on the link within the label too
+            # A link within a link, extend the label to the ]] after it
+            extended_match = extended_label_pattern.search(text, pos=end)
+            if not extended_match:
+                # TODO: Unclosed link label, what happens there?
+                start = end
+                continue
+            groups['label'] += groups['linktrail'] + extended_match[1]
+            groups['linktrail'] = extended_match[2]
+            end = extended_match.end()
+
+        try:
+            link = pywikibot.Link.create_separated(
+                groups['title'], site, section=groups['section'],
+                label=groups['label'])
+        except (SiteDefinitionError, InvalidTitleError):
+            start = end
+            continue
+
+        return _LinkMatch(link, groups, match.start(), end)
+
+    return None
+
+
+def _link_title_section(link: pywikibot.Link) -> str:
+    """Return a link title with its optional section."""
+    title = link.title
+    if link.section:
+        title += '#' + link.section
+    return title
+
+
+def _link_label(groups: Mapping[str, str | None]) -> str:
+    """Return the visible label and link trail for a matched link."""
+    label = groups['label']
+    if not label:
+        label = groups['title']
+        assert label
+        if label[0] == ':':
+            label = label[1:]
+
+    linktrail = groups['linktrail']
+    if linktrail:
+        label += linktrail
+    return label
+
+
+def _should_pipe_link(
+    link: pywikibot.Link,
+    label: str,
+    must_piped: bool,
+    site: pywikibot.site.BaseSite,
+    linktrail: re.Pattern[str],
+) -> bool:
+    """Return whether *label* must be explicit in the rendered link."""
+    parsed_label = pywikibot.Link(label, link.site)
+    try:
+        parsed_label.parse()
+    except InvalidTitleError:
+        return must_piped
+
+    parsed_title = _link_title_section(parsed_label)
+    link_title = _link_title_section(link)
+    if linktrail.sub('', parsed_title[len(link_title):]):
+        return must_piped
+
+    label_prefix = label[:-len(parsed_title)]
+    link_prefix = link.canonical_title()[:-len(link.title)]
+    if link.site != site:
+        link_prefix = f':{link.site.code}:{link_prefix}'
+    return (not parsed_title.startswith(link_title)
+            or parsed_label.namespace != link.namespace
+            or label_prefix.lower() != link_prefix.lower())
+
+
+def _render_link_target(
+    match: _LinkMatch,
+    new_link,
+    site: pywikibot.site.BaseSite,
+    linktrail: re.Pattern[str],
+) -> str:
+    """Render a Page or Link replacement."""
+    is_link = isinstance(new_link, pywikibot.Link)
+    if not is_link:
+        new_link = new_link._link
+
+    new_title = new_link.canonical_title()
+    if new_link.site != site:
+        new_title = ':' + new_link.site.code + ':' + new_title
+
+    if is_link:
+        new_label = new_link.anchor
+        must_piped = new_label is not None
+        new_section = new_link.section
+    else:
+        new_label = _link_label(match.groups)
+        must_piped = True
+        new_section = match.groups['section']
+
+    if new_section:
+        new_title += '#' + new_section
+
+    if new_label is None:
+        new_label = new_title
+
+    must_piped = _should_pipe_link(
+        new_link, new_label, must_piped, site, linktrail)
+    if must_piped:
+        return f'[[{new_title}|{new_label}]]'
+
+    return (f'[[{new_label[:len(new_title)]}]]'
+            f'{new_label[len(new_title):]}')
+
+
+def _render_link_replacement(
+    match: _LinkMatch,
+    replacement,
+    site: pywikibot.site.BaseSite,
+    linktrail: re.Pattern[str],
+) -> str:
+    """Render a callback result for a matched link."""
+    if replacement is False:
+        return _link_label(match.groups)
+
+    if isinstance(replacement, str):
+        return replacement
+
+    if isinstance(replacement, bytes):
+        raise ValueError('The result must be str and not bytes.')
+
+    _validate_link_replacement(replacement)
+    return _render_link_target(match, replacement, site, linktrail)
+
+
 def replace_links(text: str, replace, site: pywikibot.site.BaseSite) -> str:
     """Replace wikilinks selectively.

@@ -873,50 +1076,11 @@
     :raises ValueError: Wrong replacement number
     :raises ValueError: Wrong replacement types
     """
-    def to_link(source):
-        """Return the link from source when it's a Page otherwise itself."""
-        if isinstance(source, pywikibot.Page):
-            return source._link
-        if isinstance(source, str):
-            return pywikibot.Link(source, site)
-        return source
-
-    def replace_callable(link, text, groups, rng):
-        if replace_list[0] == link:
-            return replace_list[1]
-        return None
-
-    def check_classes(replacement) -> None:
-        """Normalize the replacement into a list."""
-        if not isinstance(replacement, (pywikibot.Page, pywikibot.Link)):
-            raise ValueError('The replacement must be None, False, a'
-                             ' sequence, a Link or a str but is '
-                             f'"{type(replacement)}"')
-
-    def title_section(link) -> str:
-        title = link.title
-        if link.section:
-            title += '#' + link.section
-        return title
-
     if not isinstance(site, pywikibot.site.BaseSite):
         raise ValueError('The "site" argument must be a BaseSite not '
                          f'{type(site).__name__}.')

-    if isinstance(replace, Sequence):
-        if len(replace) != 2:
-            raise ValueError('When used as a sequence, the "replace" '
-                             'argument must contain exactly 2 items.')
-        replace_list = [to_link(replace[0]), replace[1]]
-        if not isinstance(replace_list[0], pywikibot.Link):
-            raise ValueError(
-                'The original value must be either str, Link or Page but is '
-                f'"{type(replace_list[0])}"')
-        if replace_list[1] is not False and replace_list[1] is not None:
-            if isinstance(replace_list[1], str):
-                replace_list[1] = pywikibot.Page(site, replace_list[1])
-            check_classes(replace_list[0])
-        replace = replace_callable
+    replace = _normalize_link_replacer(replace, site)
 
     linktrail = site.linktrail()
     link_pattern = re.compile(
@@ -928,144 +1092,22 @@
     curpos = 0
     # This loop will run until we have finished the current page
     while True:
-        m = link_pattern.search(text, pos=curpos)
-        if not m:
+        match = _find_next_link(
+            text, curpos, site, link_pattern, extended_label_pattern)
+        if match is None:
             break

-        m_title = m['title'].strip()
-
-        # Ignore links to sections of the same page
-        if not m_title:
-            curpos = m.end()
-            continue
-
-        # Ignore interwiki links
-        if site.isInterwikiLink(m_title) and not m_title.startswith(':'):
-            curpos = m.end()
-            continue
-
-        groups = m.groupdict()
-        if groups['label'] and '[[' in groups['label']:
-            # TODO: Work on the link within the label too
-            # A link within a link, extend the label to the ]] after it
-            extended_match = extended_label_pattern.search(text, pos=m.end())
-            if not extended_match:
-                # TODO: Unclosed link label, what happens there?
-                curpos = m.end()
-                continue
-            groups['label'] += groups['linktrail'] + extended_match[1]
-            groups['linktrail'] = extended_match[2]
-            end = extended_match.end()
-        else:
-            end = m.end()
-
-        start = m.start()
-        # Since this point the m variable shouldn't be used as it may not
-        # contain all contents
-        del m
-
-        try:
-            link = pywikibot.Link.create_separated(
-                groups['title'], site, section=groups['section'],
-                label=groups['label'])
-        except (SiteDefinitionError, InvalidTitleError):
-            # unrecognized iw prefix or invalid title
-            curpos = end
-            continue
-
         # Check whether the link found should be replaced.
         # Either None, False or tuple(Link, bool)
-        new_link = replace(link, text, groups.copy(), (start, end))
+        groups = match.groups
+        start, end = match.start, match.end
+        new_link = replace(match.link, text, groups.copy(), (start, end))
         if new_link is None:
             curpos = end
             continue

-        # The link looks like this:
-        # [[page_title|new_label]]new_linktrail
-        page_title = groups['title']
-        new_label = groups['label']
-
-        if not new_label:
-            # or like this: [[page_title]]new_linktrail
-            new_label = page_title
-            # remove preleading ":" from the link text
-            if new_label[0] == ':':
-                new_label = new_label.removeprefix(':')
-
-        new_linktrail = groups['linktrail']
-        if new_linktrail:
-            new_label += new_linktrail
-
-        if new_link is False:
-            # unlink - we remove the section if there's any
-            assert isinstance(new_label, str), 'link text must be str.'
-            new_link = new_label
-
-        if isinstance(new_link, str):
-            text = text[:start] + new_link + text[end:]
-            # Make sure that next time around we will not find this same hit.
-            curpos = start + len(new_link)
-            continue
-
-        if isinstance(new_link, bytes):
-            raise ValueError('The result must be str and not bytes.')
-
-        # Verify that it's either Link, Page or str
-        check_classes(new_link)
-        # Use section and label if it's a Link and not otherwise
-        if isinstance(new_link, pywikibot.Link):
-            is_link = True
-        else:
-            new_link = new_link._link
-            is_link = False
-
-        new_title = new_link.canonical_title()
-        # Make correct langlink if needed
-        if new_link.site != site:
-            new_title = ':' + new_link.site.code + ':' + new_title
-
-        if is_link:
-            # Use link's label
-            new_label = new_link.anchor
-            must_piped = new_label is not None
-            new_section = new_link.section
-        else:
-            must_piped = True
-            new_section = groups['section']
-
-        if new_section:
-            new_title += '#' + new_section
-
-        if new_label is None:
-            new_label = new_title
-
-        # Parse the link text and check if it points to the same page
-        parsed_new_label = pywikibot.Link(new_label, new_link.site)
-        try:
-            parsed_new_label.parse()
-        except InvalidTitleError:
-            pass
-        else:
-            parsed_link_title = title_section(parsed_new_label)
-            new_link_title = title_section(new_link)
-            # compare title, but only with parts if linktrail works
-            if not linktrail.sub('',
-                                 parsed_link_title[len(new_link_title):]):
-                label_prefix = new_label[:-len(parsed_link_title)]
-                link_prefix = new_link.canonical_title()[:-len(new_link.title)]
-                if new_link.site != site:
-                    link_prefix = f':{new_link.site.code}:{link_prefix}'
-                must_piped = (
-                    not parsed_link_title.startswith(new_link_title)
-                    or parsed_new_label.namespace != new_link.namespace
-                    or label_prefix.lower() != link_prefix.lower())
-
-        if must_piped:
-            new_text = f'[[{new_title}|{new_label}]]'
-        else:
-            new_text = (f'[[{new_label[:len(new_title)]}]]'
-                        f'{new_label[len(new_title):]}')
-
+        new_text = _render_link_replacement(
+            match, new_link, site, linktrail)
         text = text[:start] + new_text + text[end:]
         # Make sure that next time around we will not find this same hit.
         curpos = start + len(new_text)
diff --git a/tests/textlib_tests.py b/tests/textlib_tests.py
index 7253bcf..fb55159 100755
--- a/tests/textlib_tests.py
+++ b/tests/textlib_tests.py
@@ -794,6 +794,60 @@
             textlib.replace_links(self.text, callback, self.wp_site),
             'Hello [[Homeworld]], [[how|are]] you? Are you a [[bug:1337]]?')

+    def test_callback_context(self) -> None:
+        """Test callback data and ranges after changing the text."""
+        calls = []
+
+        def callback(link, text, groups, rng):
+            calls.append((link.title, text, groups, rng))
+            return 'expanded replacement' if link.title == 'A' else None
+
+        source = 'Start [[A|label]]s [[B]] end'
+        result = textlib.replace_links(source, callback, self.wp_site)
+        changed = 'Start expanded replacement [[B]] end'
+
+        self.assertEqual(result, changed)
+        self.assertEqual(calls, [
+            ('A', source, {
+                'title': 'A',
+                'section': None,
+                'label': 'label',
+                'linktrail': 's',
+            }, (6, 18)),
+            ('B', changed, {
+                'title': 'B',
+                'section': None,
+                'label': None,
+                'linktrail': '',
+            }, (27, 32)),
+        ])
+
+    def test_skipped_links(self) -> None:
+        """Test that same-page sections and interwiki links are skipped."""
+        links = []
+
+        def callback(link, text, groups, rng):
+            links.append(link.title)
+            return False
+
+        source = '[[#Section]] [[fr:Page]] [[Valid]]'
+        result = textlib.replace_links(source, callback, self.wp_site)
+
+        self.assertEqual(result, '[[#Section]] [[fr:Page]] Valid')
+        self.assertEqual(links, ['Valid'])
+
+    def test_invalid_arguments(self) -> None:
+        """Test invalid site and sequence replacement arguments."""
+        with self.assertRaisesRegex(
+                ValueError, 'must be a BaseSite not object'):
+            textlib.replace_links('', lambda *_: None, object())
+
+        with self.assertRaisesRegex(ValueError, 'exactly 2 items'):
+            textlib.replace_links('', ('A',), self.wp_site)
+
+        with self.assertRaisesRegex(ValueError, 'must be either str'):
+            textlib.replace_links('', (object(), False), self.wp_site)
+
     def test_replacements_once(self) -> None:
         """Test dynamic replacement."""
         def callback(link, text, groups, rng):

--
To view, visit 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1324287?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: Idb2bff6884d6cefb448d77b3103554a01c872c98
Gerrit-Change-Number: 1324287
Gerrit-PatchSet: 4
Gerrit-Owner: Mahveotm <[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