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]