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

Change subject: IMPR: refactor Site.rollbackpage
......................................................................

IMPR: refactor Site.rollbackpage

- Add *pageid* parameter as alternative to *page* and add checks for them
- Set defaults for *markbot* if  not explicitly given
- The method now returns a dictionary with rollback information
- No longer search the history for different users, let the API check it
- Raise NoPageError of pageid does not exists
- Modify PageRelatedError to enable exception for pageid
- Add rollback() method to BasePage
- Use BasePage.rollback in BaseRevertBot
- Modify API result when simulate is set
- Add TestRollbackPage to site_tests
- Update documentation

Bug: T403425
Change-Id: Ibb559d810e44a52fcf1167b8d2d7f07de374fecc
---
M docs/mwapi.rst
M pywikibot/data/api/_requests.py
M pywikibot/exceptions.py
M pywikibot/page/_basepage.py
M pywikibot/site/_apisite.py
M scripts/pyproject.toml
M scripts/revertbot.py
M tests/site_tests.py
8 files changed, 246 insertions(+), 53 deletions(-)

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




diff --git a/docs/mwapi.rst b/docs/mwapi.rst
index f8a9748..10a90e0 100644
--- a/docs/mwapi.rst
+++ b/docs/mwapi.rst
@@ -103,7 +103,7 @@
      -
    * - :api:`rollback<rollback>`
      - :meth:`rollbackpage()<pywikibot.site._apisite.APISite.rollbackpage>`
-     -
+     - meth:`BasePage.rollback()<page.BasePage.rollback>`
      -
    * - :api:`shortenurl<shortenurl>`
      - 
:meth:`create_short_link()<pywikibot.site._extensions.UrlShortenerMixin.create_short_link>`
diff --git a/pywikibot/data/api/_requests.py b/pywikibot/data/api/_requests.py
index 07fa07f..a49bc53 100644
--- a/pywikibot/data/api/_requests.py
+++ b/pywikibot/data/api/_requests.py
@@ -532,8 +532,16 @@
             # for more realistic simulation
             if config.simulate is not True:
                 pywikibot.sleep(float(config.simulate))
+            if action == 'rollback':
+                result = {
+                    'title': self._params['title'][0].title(),
+                    'summary': self._params.get('summary',
+                                                ['Rollback simulation'])[0],
+                }
+            else:
+                result = {'result': 'Success', 'nochange': ''}
             return {
-                action: {'result': 'Success', 'nochange': ''},
+                action: result,

                 # wikibase results
                 'entity': {'lastrevid': -1, 'id': '-1'},
diff --git a/pywikibot/exceptions.py b/pywikibot/exceptions.py
index 66ae9b0..00f1a26 100644
--- a/pywikibot/exceptions.py
+++ b/pywikibot/exceptions.py
@@ -172,7 +172,7 @@
    instead.
 """
 #
-# (C) Pywikibot team, 2008-2023
+# (C) Pywikibot team, 2008-2025
 #
 # Distributed under the terms of the MIT license.
 #
@@ -286,17 +286,20 @@

     This class should be used when the Exception concerns a particular
     Page, and when a generic message can be written once for all.
+
+    .. versionchanged:: 10.5
+       A pageid is accepted with the first parameter
     """

     # Preformatted message where the page title will be inserted.
     # Override this in subclasses.
     message = ''

-    def __init__(self, page: pywikibot.page.BasePage,
+    def __init__(self, page: pywikibot.page.BasePage | int,
                  message: str | None = None) -> None:
         """Initializer.

-        :param page: Page that caused the exception
+        :param page: Page object or pageid that caused the exception
         """
         if message:
             self.message = message
@@ -305,13 +308,17 @@
             raise Error("PageRelatedError is abstract. Can't instantiate it!")

         self.page = page
-        self.title = page.title(as_link=True)
-        self.site = page.site
+        if isinstance(page, pywikibot.page.BasePage):
+            self.title = str(page)
+            self.site = page.site
+        else:
+            self.title = f'{page} (pageid)'
+            self.site = ''

         if re.search(r'\{\w+\}', self.message):
             msg = self.message.format_map(self.__dict__)
         else:
-            msg = self.message.format(page)
+            msg = self.message.format(self.title)

         super().__init__(msg)

diff --git a/pywikibot/page/_basepage.py b/pywikibot/page/_basepage.py
index 1e7ca3f..f10c1d1 100644
--- a/pywikibot/page/_basepage.py
+++ b/pywikibot/page/_basepage.py
@@ -2004,6 +2004,50 @@
                                   noredirect=noredirect,
                                   movesubpages=movesubpages)

+    def rollback(self, **kwargs: Any) -> dict[str, int | str]:
+        """Roll back this page to the version before the last edit by a user.
+
+        .. versionadded:: 10.5
+
+        .. seealso::
+           :meth:`Site.rollbackpage()
+           <pywikibot.site._apisite.APISite.rollbackpage>`
+
+        :keyword tags: Tags to apply to the rollback.
+        :kwtype tags: str | Sequence[str] | None
+        :keyword str user: The last user to be rolled back; Default is
+            :attr:`BasePage.latest_revision.user
+            <page.BasePage.latest_revision>`.
+        :keyword str | None summary: Custom edit summary for the rollback
+        :keyword bool | None markbot: Mark the reverted edits and the
+            revert as bot edits. If not given, it is set to True if the
+            rollback user belongs to the 'bot' group, otherwise False.
+        :keyword watchlist: Unconditionally add or remove the page from
+            the current user's watchlist; 'preferences' is ignored for
+            bot users.
+        :kwtype watchlist: Literal['watch', 'unwatch', 'preferences',
+            'nochange'] | None
+        :keyword watchlistexpiry: Watchlist expiry timestamp. Omit this
+            parameter entirely to leave the current expiry unchanged.
+        :kwtype watchlistexpiry: pywikibot.Timestamp | str | Literal[
+            'infinite', 'indefinite', 'infinity', 'never'] | None
+        :returns: Dictionary containing rollback result like
+
+            .. code:: python
+
+               {
+                   'title': <page title>,
+                   'pageid': <page ID>,
+                   'summary': <rollback summary>,
+                   'revid': <ID of the new revision created by the rollback>,
+                   'old_revid': <ID of the newest revision being rolled back>,
+                   'last_revid': <ID of the revision restored by the rollback>,
+               }
+
+        raises exceptions.Error: The rollback fails.
+        """
+        return self.site.rollbackpage(self, **kwargs)
+
     def delete(
         self,
         reason: str | None = None,
diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py
index c52c2ba..c4f2764 100644
--- a/pywikibot/site/_apisite.py
+++ b/pywikibot/site/_apisite.py
@@ -2505,68 +2505,122 @@

     # catalog of rollback errors for use in error messages
     _rb_errors = {
-        'noapiwrite': 'API editing not enabled on {site} wiki',
-        'writeapidenied': 'User {user} not allowed to edit through the API',
-        'alreadyrolled':
-            'Page [[{title}]] already rolled back; action aborted.',
-    }  # other errors shouldn't arise because we check for those errors
+        'alreadyrolled': 'The last edit of page {title!r} by user {user!r} '
+                         'was already rolled back.',
+        'onlyauthor': 'The page {title!r} has only {user!r} as author',
+    }  # standard error messages raises API error

     @need_right('rollback')
     def rollbackpage(
         self,
-        page: BasePage,
+        page: BasePage | None = None,
+        *,
+        pageid: int | None = None,
         **kwargs: Any
-    ) -> None:
-        """Roll back page to version before last user's edits.
+    ) -> dict[str, int | str]:
+        """Roll back a page to the version before the last edit by a user.

-        .. seealso:: :api:`Rollback`
+        This method wraps the MediaWiki :api:`Rollback`. The rollback
+        will revert the last edit(s) made by the specified user on the
+        given page.

-        The keyword arguments are those supported by the rollback API.
+        .. versionchanged:: 10.5
+           Added *pageid* as alternative to *page* (one must be given).
+           *markbot* defaults to True if the rollbacker is a bot and not
+           explicitly given. The method now returns a dictionary with
+           rollback information.

-        As a precaution against errors, this method will fail unless
-        the page history contains at least two revisions, and at least
-        one that is not by the same user who made the last edit.
+        .. seealso::
+           :meth:`page.BasePage.rollback`

-        :param page: the Page to be rolled back (must exist)
-        :keyword user: the last user to be rollbacked;
-            default is page.latest_revision.user
+        :param page: the Page to be rolled back. Cannot be used together
+            with *pageid*.
+        :param pageid: Page ID of the page to be rolled back. Cannot be
+            used together with *page*.
+        :keyword tags: Tags to apply to the rollback.
+        :kwtype tags: str | Sequence[str] | None
+        :keyword str user: The last user to be rolled back; Must be
+            given with *pageid*. Default is
+            :attr:`BasePage.latest_revision.user
+            <page.BasePage.latest_revision>` if *page* is given.
+        :keyword str | None summary: Custom edit summary for the rollback
+        :keyword bool | None markbot: Mark the reverted edits and the
+            revert as bot edits. If not given, it is set to True if the
+            rollback user belongs to the 'bot' group, otherwise False.
+        :keyword watchlist: Unconditionally add or remove the page from
+            the current user's watchlist; 'preferences' is ignored for
+            bot users.
+        :kwtype watchlist: Literal['watch', 'unwatch', 'preferences',
+            'nochange'] | None
+        :keyword watchlistexpiry: Watchlist expiry timestamp. Omit this
+            parameter entirely to leave the current expiry unchanged.
+        :kwtype watchlistexpiry: pywikibot.Timestamp | str | Literal[
+            'infinite', 'indefinite', 'infinity', 'never'] | None
+        :returns: Dictionary containing rollback result like
+
+            .. code:: python
+
+               {
+                   'title': <page title>,
+                   'pageid': <page ID>,
+                   'summary': <rollback summary>,
+                   'revid': <ID of the new revision created by the rollback>,
+                   'old_revid': <ID of the newest revision being rolled back>,
+                   'last_revid': <ID of the revision restored by the rollback>,
+               }
+
+        :raises APIError: An error was returned by the rollback API, or
+            another standard API error occurred.
+        :raises Error: The page was already rolled back, or the given
+            *user* is the only author.
+        :raises NoPageError: The given *page* or *pageid* does not exist.
+        :raises TypeError: *pageid* is of invalid type.
+        :raises ValueError: Both *page* and *pageid* were given, or none
+            of them, or *pageid* has an invalid value.
         """
-        if len(page._revisions) < 2:
-            raise Error(
-                f'Rollback of {page} aborted; load revision history first.')
+        if page is not None and pageid is not None:
+            raise ValueError(
+                "The parameters 'page' and 'pageid' cannot be used together.")
+
+        if page is None and pageid is None:
+            raise ValueError(
+                "One of parameters 'page' or 'pageid' is required.")
+
+        if page is None and pageid is not None:
+            page = next(self.load_pages_from_pageids(str(pageid)), None)
+
+        if page is None:
+            raise NoPageError(pageid)

         user = kwargs.pop('user', page.latest_revision.user)
-        for rev in sorted(page._revisions.values(), reverse=True,
-                          key=lambda r: r.timestamp):
-            # start with most recent revision first
-            if rev.user != user:
-                break
-        else:
-            raise Error(f'Rollback of {page} aborted; only one user in '
-                        f'revision history.')
+        params = merge_unique_dicts(
+            kwargs,
+            action='rollback',
+            title=page,
+            token=self.tokens['rollback'],
+            user=user,
+        )

-        parameters = merge_unique_dicts(kwargs,
-                                        action='rollback',
-                                        title=page,
-                                        token=self.tokens['rollback'],
-                                        user=user)
+        rb_user = self.user()
+        if rb_user is not None and 'markbot' not in kwargs:
+            params['markbot'] = self.has_group('bot')
+
         self.lock_page(page)
-        req = self.simple_request(**parameters)
+        req = self.simple_request(**params)
         try:
-            req.submit()
+            result = req.submit()
         except APIError as err:
             errdata = {
-                'site': self,
                 'title': page.title(with_section=False),
-                'user': self.user(),
+                'user': user,
             }
             if err.code in self._rb_errors:
                 raise Error(
                     self._rb_errors[err.code].format_map(errdata)
                 ) from None
-            pywikibot.debug(
-                f"rollback: Unexpected error code '{err.code}' received.")
             raise
+        else:
+            return result['rollback']
         finally:
             self.unlock_page(page)

diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml
index 0886321..72811b7 100644
--- a/scripts/pyproject.toml
+++ b/scripts/pyproject.toml
@@ -19,7 +19,7 @@
 readme = "scripts/README.rst"
 requires-python = ">=3.8.0"
 dependencies = [
-    "pywikibot >= 10.4.0",
+    "pywikibot >= 10.5.0",
     "isbnlib",
     "langdetect",
     "mwparserfromhell",
diff --git a/scripts/revertbot.py b/scripts/revertbot.py
index 64fe498..015871b 100755
--- a/scripts/revertbot.py
+++ b/scripts/revertbot.py
@@ -36,12 +36,14 @@
             return False
 """
 #
-# (C) Pywikibot team, 2008-2024
+# (C) Pywikibot team, 2008-2025
 #
 # Distributed under the terms of the MIT license.
 #
 from __future__ import annotations

+from textwrap import fill
+
 import pywikibot
 from pywikibot import i18n
 from pywikibot.backports import Container
@@ -84,7 +86,8 @@
             if callback(item):
                 result = self.revert(item)
                 if result:
-                    pywikibot.info(f"{item['title']}: {result}")
+                    pywikibot.info(
+                        fill(f"{item['title']}: {result}", width=77))
                 else:
                     pywikibot.info(f"Skipped {item['title']}")
             else:
@@ -134,17 +137,17 @@
             return comment

         try:
-            self.site.rollbackpage(page, user=self.user, markbot=True)
+            result = page.rollback(user=self.user)
         except APIError as e:
             if e.code == 'badtoken':
                 pywikibot.error(
-                    'There was an API token error rollbacking the edit')
+                    'There was an API token error rolling back the edit')
                 return False
         except Error:
             pass
         else:
-            return (f'The edit(s) made in {page.title()} by {self.user}'
-                    ' was rollbacked')
+            return (f'The edit(s) made in {result["title"]} by {self.user} '
+                    f'was rolled back to revision {result["last_revid"]}')

         pywikibot.exception(exc_info=False)
         return False
diff --git a/tests/site_tests.py b/tests/site_tests.py
index 8c3df54..ba06e32 100755
--- a/tests/site_tests.py
+++ b/tests/site_tests.py
@@ -31,6 +31,7 @@
     DefaultDrySiteTestCase,
     DefaultSiteTestCase,
     DeprecationTestCase,
+    PatchingTestCase,
     TestCase,
     WikimediaDefaultSiteTestCase,
 )
@@ -784,6 +785,82 @@
         site.undelete(fp, 'pywikibot unit tests', fileids=[fileid])


+class TestRollbackPage(PatchingTestCase):
+
+    """Test rollbackpage site method."""
+
+    family = 'wikipedia'
+    code = 'test'
+    login = True
+
+    @staticmethod
+    @PatchingTestCase.patched(pywikibot.data.api.Request, '_simulate')
+    def _simulate(self, action):
+        """Patch api.Request._simulate. Note: self is the Request instance."""
+        if action == 'rollback':
+            result = {
+                'title': self._params['title'][0].title(),
+                'summary': self._params.get('summary',
+                                            ['Rollback simulation'])[0],
+                'last_revid': 381070,
+            }
+            return {action: result}
+
+        if action and config.simulate and self.write:
+            result = {'result': 'Success', 'nochange': ''}
+            return {action: result}
+
+        return None
+
+    @classmethod
+    def setUpClass(cls):
+        """Use sandbox page for tests."""
+        super().setUpClass()
+        cls.page = pywikibot.Page(cls.site, 'Sandbox')
+
+    def setUp(self):
+        """Patch has_right method."""
+        super().setUp()
+        self.patch(self.site, 'has_right', lambda right: True)
+
+    def test_missing_rights(self):
+        """Test missing rollback right."""
+        self.patch(self.site, 'has_right', lambda right: False)
+        with self.assertRaisesRegex(
+            Error,
+            r'User "\w+" does not have required user right "rollback" on site'
+        ):
+            self.site.rollbackpage(self.page, pageid=4711)
+
+    def test_exceptions(self):
+        """Test rollback exceptions."""
+        with self.assertRaisesRegex(
+            ValueError,
+            "The parameters 'page' and 'pageid' cannot be used together"
+        ):
+            self.site.rollbackpage(self.page, pageid=4711)
+
+        with self.assertRaisesRegex(
+            ValueError,
+            r"One of parameters 'page' or 'pageid' is required\."
+        ):
+            self.site.rollbackpage()
+
+        with self.assertRaisesRegex(
+                NoPageError, r"Page -1 \(pageid\) doesn't exist\."):
+            self.site.rollbackpage(pageid=-1)
+
+    def test_rollback_simulation(self):
+        """Test rollback in simulate mode."""
+        result = self.site.rollbackpage(self.page)
+        self.assertIsInstance(result, dict)
+        self.assertEqual(result['title'], self.page.title())
+        self.assertEqual(result['last_revid'], 381070)
+        self.assertEqual(result['summary'], 'Rollback simulation')
+        result = self.site.rollbackpage(self.page, summary='Rollback test')
+        self.assertEqual(result['summary'], 'Rollback test')
+
+
 class TestUsernameInUsers(DefaultSiteTestCase):

     """Test that the user account can be found in users list."""

--
To view, visit 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1185306?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: Ibb559d810e44a52fcf1167b8d2d7f07de374fecc
Gerrit-Change-Number: 1185306
Gerrit-PatchSet: 11
Gerrit-Owner: Xqt <[email protected]>
Gerrit-Reviewer: Matěj Suchánek <[email protected]>
Gerrit-Reviewer: jenkins-bot
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to