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

Change subject: bot: Prevent BaseBot.available_options mutation by 
update_options
......................................................................

bot: Prevent BaseBot.available_options mutation by update_options

BaseBot.__init__ previously called
self.available_options.update(self.update_options) directly on the
inherited dictionary. Because available_options is a class attribute on
BaseBot and its subclasses, this mutated the class-level dictionary in
place for the lifetime of the process.

Any subsequent bot instantiation (such as an UploadRobot created after a
bot subclass defining update_options = {'always': True}) inherited the
polluted class dictionary, silently overriding default option values.

Copy self.available_options before updating it in BaseBot.__init__,
ensuring instance-level option updates remain isolated from class-level
defaults. Add comprehensive unit tests in tests/bot_tests.py and document
the fix in ROADMAP.rst.

Bug: T439883
Change-Id: I2b721871c6155d1827ce0ac2140b46a4fbf6ace8
---
M ROADMAP.rst
M pywikibot/bot.py
M tests/bot_tests.py
3 files changed, 111 insertions(+), 1 deletion(-)

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




diff --git a/ROADMAP.rst b/ROADMAP.rst
index 52268ad..48a8e5f 100644
--- a/ROADMAP.rst
+++ b/ROADMAP.rst
@@ -1,7 +1,8 @@
 Release 11.10
 =============

-* (no changes yet)
+* Avoid mutating class attribute 
:attr:`BaseBot.available_options<bot.BaseBot.available_options>`
+  with :attr:`BaseBot.update_options<bot.BaseBot.update_options>`. 
(:phab:`T439883`)

 Deprecations
 ============
diff --git a/pywikibot/bot.py b/pywikibot/bot.py
index 4afbfa3..37a1562 100644
--- a/pywikibot/bot.py
+++ b/pywikibot/bot.py
@@ -1192,6 +1192,10 @@
     `self.available_options.update(<dict>)` initializer in such case.

     .. version-added:: 6.4
+    .. version-changed:: 11.10
+       :attr:`available_options` is copied in :meth:`__init__` before updating
+       with :attr:`update_options` to prevent modifying class attributes
+       (:phab:`T439883`).
     """

     _current_page: pywikibot.page.BasePage | None = None
@@ -1211,6 +1215,7 @@
             else:
                 self.generator: Iterable = gen

+        self.available_options = self.available_options.copy()
         self.available_options.update(self.update_options)
         super().__init__(**kwargs)

diff --git a/tests/bot_tests.py b/tests/bot_tests.py
index 7de4e39..572c595 100755
--- a/tests/bot_tests.py
+++ b/tests/bot_tests.py
@@ -17,6 +17,7 @@
 import pywikibot
 import pywikibot.bot
 from pywikibot import config, i18n
+from pywikibot.specialbots import UploadRobot
 from tests.aspects import DefaultSiteTestCase, SiteAttributeTestCase, TestCase
 from tests.utils import execute

@@ -385,6 +386,109 @@
         self.assertNotIn('baz', oh.opt.__dict__)


+class BotWithUpdateOptions(pywikibot.bot.BaseBot):
+
+    """A derived BaseBot class with update_options."""
+
+    update_options = {
+        'always': True,
+        'custom_opt': 42,
+    }
+
+
+class BotWithAvailableAndUpdateOptions(pywikibot.bot.BaseBot):
+
+    """A derived BaseBot class with available_options and update_options."""
+
+    available_options = {
+        'sub_opt': 1,
+    }
+    update_options = {
+        'sub_opt': 2,
+        'new_opt': 3,
+    }
+
+
+class TestBotOptions(TestCase):
+
+    """Test BaseBot available_options and update_options isolation."""
+
+    dry = True
+    family = 'wikipedia'
+    code = 'test'
+
+    def setUp(self) -> None:
+        """Capture BaseBot.available_options state before test."""
+        super().setUp()
+        self._orig_basebot_options = (
+            pywikibot.bot.BaseBot.available_options.copy())
+
+    def tearDown(self) -> None:
+        """Restore BaseBot.available_options state after test."""
+        pywikibot.bot.BaseBot.available_options = self._orig_basebot_options
+        super().tearDown()
+
+    def test_update_options_isolation(self) -> None:
+        """Test that update_options does not mutate available_options."""
+        self.assertIn('always', pywikibot.bot.BaseBot.available_options)
+        self.assertFalse(pywikibot.bot.BaseBot.available_options['always'])
+        self.assertNotIn('custom_opt', pywikibot.bot.BaseBot.available_options)
+
+        bot1 = BotWithUpdateOptions()
+        self.assertTrue(bot1.opt.always)
+        self.assertEqual(bot1.opt.custom_opt, 42)
+
+        # BaseBot.available_options must remain unmutated (T439883)
+        self.assertFalse(pywikibot.bot.BaseBot.available_options['always'])
+        self.assertNotIn('custom_opt', pywikibot.bot.BaseBot.available_options)
+
+        # A subsequently instantiated bot retains the default
+        bot2 = pywikibot.bot.BaseBot()
+        self.assertFalse(bot2.opt.always)
+        self.assertNotIn('custom_opt', bot2.opt)
+
+        # Subclass-specific and caller-provided options behave as intended
+        bot3 = BotWithUpdateOptions(always=False, custom_opt=100)
+        self.assertFalse(bot3.opt.always)
+        self.assertEqual(bot3.opt.custom_opt, 100)
+        self.assertFalse(pywikibot.bot.BaseBot.available_options['always'])
+
+    def test_upload_robot_after_update_options(self) -> None:
+        """Test UploadRobot initialization after bot with update_options."""
+        # Instantiate a bot that overrides 'always' in update_options
+        BotWithUpdateOptions()
+
+        # Simulate UploadRobot initialization from data_ingestion.py (T439883)
+        bot = UploadRobot(url='https://example.com/test.png',
+                          description='test description',
+                          use_filename='Test.png',
+                          keep_filename=True,
+                          verify_description=False,
+                          target_site=self.get_site())
+        self.assertFalse(bot.opt.always)
+
+    def test_subclass_options_isolation(self) -> None:
+        """Test subclass with available_options and update_options."""
+        self.assertEqual(
+            BotWithAvailableAndUpdateOptions.available_options,
+            {'sub_opt': 1})
+
+        bot1 = BotWithAvailableAndUpdateOptions()
+        self.assertEqual(bot1.opt.sub_opt, 2)
+        self.assertEqual(bot1.opt.new_opt, 3)
+        self.assertEqual(
+            BotWithAvailableAndUpdateOptions.available_options,
+            {'sub_opt': 1})
+        self.assertFalse(pywikibot.bot.BaseBot.available_options['always'])
+
+        bot2 = BotWithAvailableAndUpdateOptions(sub_opt=99, new_opt=100)
+        self.assertEqual(bot2.opt.sub_opt, 99)
+        self.assertEqual(bot2.opt.new_opt, 100)
+        self.assertEqual(
+            BotWithAvailableAndUpdateOptions.available_options,
+            {'sub_opt': 1})
+
+
 if __name__ == '__main__':
     with suppress(SystemExit):
         unittest.main()

-- 
To view, visit 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1356991?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: I2b721871c6155d1827ce0ac2140b46a4fbf6ace8
Gerrit-Change-Number: 1356991
Gerrit-PatchSet: 2
Gerrit-Owner: Mudit Ranjan <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
Gerrit-CC: Mahveotm <[email protected]>
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to