From 40a7119bc652777d1981c9d9067987ae094e150e Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 5 Jul 2025 16:17:18 +0200 Subject: [PATCH 001/279] [tests] enable SiteRandomTestCase for beta sites Solved upstream Bug: T282602 Bug: T345874 Change-Id: Ib80091a2190fa8f5c881ff3d59ead58dbaa8c4f9 --- tests/site_generators_tests.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index 52e729f01d..a36749a94f 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -1584,15 +1584,6 @@ class SiteRandomTestCase(DefaultSiteTestCase): """Test random methods of a site.""" - @classmethod - def setUpClass(cls) -> None: - """Skip test on beta due to T282602.""" - super().setUpClass() - site = cls.get_site() - if site.family.name in ('wpbeta', 'wsbeta'): - cls.skipTest(cls, - f'Skipping test on {site} due to T282602') - def test_unlimited_small_step(self) -> None: """Test site.randompages() continuation. From a7bfdbd108305cf3aede8ac97377d51b080cdd24 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 5 Jul 2025 16:51:56 +0200 Subject: [PATCH 002/279] tests: update pre-commit hooks Change-Id: Idda3ad532008e7d8d31fe64d70e44bfe52a21ae9 --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 04b0fe62da..a8da2f5ed1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -64,7 +64,7 @@ repos: files: .+\.py$ language: python - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.1 + rev: v0.12.2 hooks: - id: ruff-check alias: ruff @@ -93,7 +93,7 @@ repos: - id: isort exclude: ^pwb\.py$ - repo: https://github.com/jshwi/docsig - rev: v0.69.4 + rev: v0.70.0 hooks: - id: docsig exclude: ^(tests|scripts) From a968606c098559e9de4497ff88dfd84d11e73e0c Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 5 Jul 2025 18:13:11 +0200 Subject: [PATCH 003/279] cleanup: remove ParamInfo._prefixes and ParamInfo._with_limits Both were desupported in release 2.0 and removed with release 5.6 Bug: T100779 Bug: T109168 Change-Id: Ib9f73c7c7e1facad9470cc57680f8ae67640b9ee --- pywikibot/data/api/_paraminfo.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pywikibot/data/api/_paraminfo.py b/pywikibot/data/api/_paraminfo.py index 49a8c2248e..e9bf0af8da 100644 --- a/pywikibot/data/api/_paraminfo.py +++ b/pywikibot/data/api/_paraminfo.py @@ -1,6 +1,6 @@ """Object representing API parameter information.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -45,12 +45,10 @@ def __init__(self, self.site = site # Keys are module names, values are the raw responses from the server. - self._paraminfo = {} + self._paraminfo: dict[str, Any] = {} # Cached data. - self._prefixes = {} self._prefix_map = {} - self._with_limits = None self._action_modules = frozenset() # top level modules self._modules = {} # filled in _init() (and enlarged in fetch) From 44f9aadb51dbf7d698378a8c1b96cfbc6856db57 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 6 Jul 2025 13:39:26 +0200 Subject: [PATCH 004/279] doc: add annotation fixes in link_tests Change-Id: I3f22d180be953a32bf24c36c8f815c4a5354142d --- tests/link_tests.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/link_tests.py b/tests/link_tests.py index 8b8a001a4a..ae6f9bd5f1 100755 --- a/tests/link_tests.py +++ b/tests/link_tests.py @@ -122,25 +122,25 @@ def test_valid(self) -> None: def test_invalid(self) -> None: """Test that invalid titles raise InvalidTitleError.""" # Bad characters forbidden regardless of wgLegalTitleChars - def generate_contains_illegal_chars_exc_regex(text): + def generate_contains_illegal_chars_exc_regex(text) -> str: return (rf'^(u|)\'{re.escape(text)}\' contains illegal char' rf'\(s\) (u|)\'{re.escape(text[2])}\'$') # Directory navigation - def generate_contains_dot_combinations_exc_regex(text): + def generate_contains_dot_combinations_exc_regex(text) -> str: return (rf'^\(contains \. / combinations\): (u|)' rf'\'{re.escape(text)}\'$') # Tilde - def generate_contains_tilde_exc_regex(text): + def generate_contains_tilde_exc_regex(text) -> str: return rf'^\(contains ~~~\): (u|)\'{re.escape(text)}\'$' # Overlength - def generate_overlength_exc_regex(text): + def generate_overlength_exc_regex(text) -> str: return rf'^\(over 255 bytes\): (u|)\'{re.escape(text)}\'$' # Namespace prefix without actual title - def generate_has_no_title_exc_regex(text): + def generate_has_no_title_exc_regex(text) -> str: return rf'^(u|)\'{re.escape(text.strip())}\' has no title\.$' title_tests = [ From 54d08e4cb90a1cf9d5c7bc2f74558c1d032d5937 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 1 Jul 2025 10:07:18 +0200 Subject: [PATCH 005/279] [IMPR] rename NON_LATIN_DIGITS to NON_ASCII_DIGITS - also rename textlib.to_latin_digits to to_ascii_digits - update any occurrence - deprecate the old variant This patch is inspired by T398146#10958283 Change-Id: I43b11df3f2141a2f571a705692d3dab28a93ef43 --- HISTORY.rst | 6 ++-- pywikibot/cosmetic_changes.py | 6 ++-- pywikibot/date.py | 12 ++++---- pywikibot/textlib.py | 34 +++++++++++++-------- pywikibot/userinterfaces/transliteration.py | 11 ++++--- scripts/archivebot.py | 8 ++--- tests/textlib_tests.py | 18 +++++------ tests/ui_tests.py | 10 +++--- 8 files changed, 59 insertions(+), 46 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 3b45a1d030..f58f9ec0a9 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -90,7 +90,7 @@ Release History * ``APISite.article_path`` was removed. :attr:`APISite.articlepath ` can be used instead. * ``fix_digits`` method of :class:`textlib.TimeStripper` was removed; - :func:`textlib.to_latin_digits` can be used instead. + :func:`textlib.to_ascii_digits` can be used instead. * :mod:`textlib`.tzoneFixedOffset class was removed in favour of :class:`time.TZoneFixedOffse`. * A boolean *watch* parameter in :meth:`page.BasePage.save` is desupported. @@ -929,9 +929,9 @@ Release History **Improvements** * i18n updates for date.py -* Add number transliteration of 'lo', 'ml', 'pa', 'te' to NON_LATIN_DIGITS +* Add number transliteration of 'lo', 'ml', 'pa', 'te' to NON_ASCII_DIGITS * Detect range blocks with Page.is_blocked() method (:phab:`T301282`) -* to_latin_digits() function was added to textlib as counterpart of to_local_digits() function +* to_ascii_digits() function was added to textlib as counterpart of to_local_digits() function * api.Request.submit now handles search-title-disabled and search-text-disabled API Errors * A show_diff parameter was added to Page.put() and Page.change_category() * Allow categories when saving IndexPage (:phab:`T299806`) diff --git a/pywikibot/cosmetic_changes.py b/pywikibot/cosmetic_changes.py index b78b8feabc..ed1380bda2 100644 --- a/pywikibot/cosmetic_changes.py +++ b/pywikibot/cosmetic_changes.py @@ -68,7 +68,7 @@ from pywikibot.site import Namespace from pywikibot.tools import first_lower, first_upper from pywikibot.tools.chars import url2string -from pywikibot.userinterfaces.transliteration import NON_LATIN_DIGITS +from pywikibot.userinterfaces.transliteration import NON_ASCII_DIGITS try: @@ -1031,10 +1031,10 @@ def fixArabicLetters(self, text: str) -> str: 'syntaxhighlight', ] - digits = NON_LATIN_DIGITS['fa'] + digits = NON_ASCII_DIGITS['fa'] faChrs = 'ءاآأإئؤبپتثجچحخدذرزژسشصضطظعغفقکگلمنوهیةيك' + digits - # not to let bot edits in latin content + # not to let bot edits in ascii numerals content exceptions.append(re.compile(f'[^{faChrs}] *?"*? *?, *?[^{faChrs}]')) text = textlib.replaceExcept(text, ',', '،', exceptions, site=self.site) diff --git a/pywikibot/date.py b/pywikibot/date.py index e6b4eae818..9c2a01ea4e 100644 --- a/pywikibot/date.py +++ b/pywikibot/date.py @@ -26,7 +26,7 @@ ) from pywikibot.site import BaseSite from pywikibot.tools import deprecate_arg, first_lower, first_upper -from pywikibot.userinterfaces.transliteration import NON_LATIN_DIGITS +from pywikibot.userinterfaces.transliteration import NON_ASCII_DIGITS if TYPE_CHECKING: @@ -288,27 +288,27 @@ def monthName(lang: str, ind: int) -> str: # Helper for KN: digits representation -_knDigits = NON_LATIN_DIGITS['kn'] +_knDigits = NON_ASCII_DIGITS['kn'] _knDigitsToLocal = {ord(str(i)): _knDigits[i] for i in range(10)} _knLocalToDigits = {ord(_knDigits[i]): str(i) for i in range(10)} # Helper for Urdu/Persian languages -_faDigits = NON_LATIN_DIGITS['fa'] +_faDigits = NON_ASCII_DIGITS['fa'] _faDigitsToLocal = {ord(str(i)): _faDigits[i] for i in range(10)} _faLocalToDigits = {ord(_faDigits[i]): str(i) for i in range(10)} # Helper for HI:, MR: -_hiDigits = NON_LATIN_DIGITS['hi'] +_hiDigits = NON_ASCII_DIGITS['hi'] _hiDigitsToLocal = {ord(str(i)): _hiDigits[i] for i in range(10)} _hiLocalToDigits = {ord(_hiDigits[i]): str(i) for i in range(10)} # Helper for BN: -_bnDigits = NON_LATIN_DIGITS['bn'] +_bnDigits = NON_ASCII_DIGITS['bn'] _bnDigitsToLocal = {ord(str(i)): _bnDigits[i] for i in range(10)} _bnLocalToDigits = {ord(_bnDigits[i]): str(i) for i in range(10)} # Helper for GU: -_guDigits = NON_LATIN_DIGITS['gu'] +_guDigits = NON_ASCII_DIGITS['gu'] _guDigitsToLocal = {ord(str(i)): _guDigits[i] for i in range(10)} _guLocalToDigits = {ord(_guDigits[i]): str(i) for i in range(10)} diff --git a/pywikibot/textlib.py b/pywikibot/textlib.py index 039bc25890..5d48567a0d 100644 --- a/pywikibot/textlib.py +++ b/pywikibot/textlib.py @@ -24,12 +24,13 @@ from pywikibot.family import Family from pywikibot.time import TZoneFixedOffset from pywikibot.tools import ( + ModuleDeprecationWrapper, deprecated, deprecated_args, first_lower, first_upper, ) -from pywikibot.userinterfaces.transliteration import NON_LATIN_DIGITS +from pywikibot.userinterfaces.transliteration import NON_ASCII_DIGITS try: @@ -111,10 +112,11 @@ def to_local_digits(phrase: str | int, lang: str) -> str: - """Change Latin digits based on language to localized version. + """Change ASCII digits based on language to localized version. - Be aware that this function only works for several languages, and that it - returns an unchanged string if an unsupported language is given. + .. attention:: Be aware that this function only works for several + languages, and that it returns an unchanged string if an + unsupported language is given. .. versionchanged:: 7.5 always return a string even `phrase` is an int. @@ -123,7 +125,7 @@ def to_local_digits(phrase: str | int, lang: str) -> str: :param lang: language code :return: The localized version """ - digits = NON_LATIN_DIGITS.get(lang) + digits = NON_ASCII_DIGITS.get(lang) phrase = str(phrase) if digits: trans = str.maketrans('0123456789', digits) @@ -131,24 +133,26 @@ def to_local_digits(phrase: str | int, lang: str) -> str: return phrase -def to_latin_digits(phrase: str, +def to_ascii_digits(phrase: str, langs: SequenceType[str] | str | None = None) -> str: - """Change non-latin digits to latin digits. + """Change non-ascii digits to ascii digits. .. versionadded:: 7.0 + .. versionchanged:: 10.3 + this function was renamed from to_latin_digits. - :param phrase: The phrase to convert to latin numerical. + :param phrase: The phrase to convert to ascii numerical. :param langs: Language codes. If langs parameter is None, use all known languages to convert. - :return: The string with latin digits + :return: The string with ascii digits """ if langs is None: - langs = NON_LATIN_DIGITS.keys() + langs = NON_ASCII_DIGITS.keys() elif isinstance(langs, str): langs = [langs] - digits = [NON_LATIN_DIGITS[key] for key in langs - if key in NON_LATIN_DIGITS] + digits = [NON_ASCII_DIGITS[key] for key in langs + if key in NON_ASCII_DIGITS] if digits: trans = str.maketrans(''.join(digits), '0123456789' * len(digits)) phrase = phrase.translate(trans) @@ -2214,7 +2218,7 @@ def censor_match(match): line = removeDisabledParts(line) line = removeHTMLParts(line) - line = to_latin_digits(line) + line = to_ascii_digits(line) for pat in self.patterns: line, match_obj = self._last_match_and_replace(line, pat) if match_obj: @@ -2269,3 +2273,7 @@ def censor_match(match): timestamp = None return timestamp + + +wrapper = ModuleDeprecationWrapper(__name__) +wrapper.add_deprecated_attr('to_latin_digits', to_ascii_digits, since='10.3.0') diff --git a/pywikibot/userinterfaces/transliteration.py b/pywikibot/userinterfaces/transliteration.py index 6fdf6f13c6..5719bd0321 100644 --- a/pywikibot/userinterfaces/transliteration.py +++ b/pywikibot/userinterfaces/transliteration.py @@ -1,6 +1,6 @@ """Module to transliterate text.""" # -# (C) Pywikibot team, 2006-2024 +# (C) Pywikibot team, 2006-2025 # # Distributed under the terms of the MIT license. # @@ -9,8 +9,8 @@ from pywikibot.tools import ModuleDeprecationWrapper, deprecate_arg -#: Non latin digits used by the framework -NON_LATIN_DIGITS = { +#: Non ascii digits used by the framework +NON_ASCII_DIGITS = { 'bn': '০১২৩৪৫৬৭৮৯', 'ckb': '٠١٢٣٤٥٦٧٨٩', 'fa': '۰۱۲۳۴۵۶۷۸۹', @@ -1096,7 +1096,7 @@ '𐬳': 'shye', '𐬴': 'sshe', '𐬵': 'he', } -for digits in NON_LATIN_DIGITS.values(): +for digits in NON_ASCII_DIGITS.values(): _trans.update({char: str(i) for i, char in enumerate(digits)}) @@ -1154,3 +1154,6 @@ def transliterate(self, char: str, default: str = '?', wrapper = ModuleDeprecationWrapper(__name__) wrapper.add_deprecated_attr('transliterator', Transliterator, since='9.0.0') +wrapper.add_deprecated_attr('NON_LATIN_DIGITS', NON_ASCII_DIGITS, + replacement_name='NON_ASCII_DIGITS', + since='10.3.0') diff --git a/scripts/archivebot.py b/scripts/archivebot.py index 3b018ede79..a19917557c 100755 --- a/scripts/archivebot.py +++ b/scripts/archivebot.py @@ -61,16 +61,16 @@ the page being archived. Variables below can be used in the value for "archive" in the template -above; numbers are **latin** digits. Alternatively you may use +above; numbers are **ascii** digits. Alternatively you may use **localized** digits. This is only available for a few site languages. -Refer :attr:`NON_LATIN_DIGITS -` whether there is a +Refer :attr:`NON_ASCII_DIGITS +` whether there is a localized one. .. list-table:: :header-rows: 1 - * - latin + * - ascii - localized - Description * - %(counter)d diff --git a/tests/textlib_tests.py b/tests/textlib_tests.py index 9ab1ff1973..6c61a3bbf5 100755 --- a/tests/textlib_tests.py +++ b/tests/textlib_tests.py @@ -990,7 +990,7 @@ class TestDigitsConversion(TestCase): net = False def test_to_local(self) -> None: - """Test converting Latin digits to local digits.""" + """Test converting ASCII digits to local digits.""" self.assertEqual(textlib.to_local_digits(299792458, 'en'), '299792458') self.assertEqual( textlib.to_local_digits(299792458, 'fa'), '۲۹۹۷۹۲۴۵۸') @@ -1001,20 +1001,20 @@ def test_to_local(self) -> None: textlib.to_local_digits('299792458', 'km'), '២៩៩៧៩២៤៥៨') def test_to_latin(self) -> None: - """Test converting local digits to Latin digits.""" - self.assertEqual(textlib.to_latin_digits('299792458'), '299792458') + """Test converting local digits to ASCII digits.""" + self.assertEqual(textlib.to_ascii_digits('299792458'), '299792458') self.assertEqual( - textlib.to_latin_digits('۲۹۹۷۹۲۴۵۸', 'fa'), '299792458') + textlib.to_ascii_digits('۲۹۹۷۹۲۴۵۸', 'fa'), '299792458') self.assertEqual( - textlib.to_latin_digits('۲۹۹۷۹۲۴۵۸ flash'), '299792458 flash') + textlib.to_ascii_digits('۲۹۹۷۹۲۴۵۸ flash'), '299792458 flash') self.assertEqual( - textlib.to_latin_digits('២៩៩៧៩២៤៥៨', 'km'), '299792458') + textlib.to_ascii_digits('២៩៩៧៩២៤៥៨', 'km'), '299792458') self.assertEqual( - textlib.to_latin_digits('២៩៩៧៩២៤៥៨'), '299792458') + textlib.to_ascii_digits('២៩៩៧៩២៤៥៨'), '299792458') self.assertEqual( - textlib.to_latin_digits('២៩៩៧៩២៤៥៨', ['km', 'en']), '299792458') + textlib.to_ascii_digits('២៩៩៧៩២៤៥៨', ['km', 'en']), '299792458') self.assertEqual( - textlib.to_latin_digits('២៩៩៧៩២៤៥៨', ['en']), '២៩៩៧៩២៤៥៨') + textlib.to_ascii_digits('២៩៩៧៩២៤៥៨', ['en']), '២៩៩៧៩២៤៥៨') class TestReplaceExcept(DefaultDrySiteTestCase): diff --git a/tests/ui_tests.py b/tests/ui_tests.py index a7c1b4ef0c..8c29d34db2 100755 --- a/tests/ui_tests.py +++ b/tests/ui_tests.py @@ -31,7 +31,7 @@ terminal_interface_unix, terminal_interface_win32, ) -from pywikibot.userinterfaces.transliteration import NON_LATIN_DIGITS, _trans +from pywikibot.userinterfaces.transliteration import NON_ASCII_DIGITS, _trans from tests.aspects import TestCase, TestCaseBase @@ -359,11 +359,13 @@ class TestTransliterationTable(TestCase): net = False - def test_latin_digits(self) -> None: - """Test that non latin digits are in transliteration table.""" - for lang, digits in NON_LATIN_DIGITS.items(): + def test_ascii_digits(self) -> None: + """Test that non ascii digits are in transliteration table.""" + for lang, digits in NON_ASCII_DIGITS.items(): with self.subTest(lang=lang): for char in digits: + self.assertTrue(char.isdigit()) + self.assertFalse(char.isascii()) self.assertIn(char, _trans, f'{char!r} not in transliteration table') From f479261d307cc199816c4c3da077dabf8b4ab3fe Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 23 Jun 2025 09:45:17 +0200 Subject: [PATCH 006/279] Tests: Fix git commands for copyright_fixer.py - use "git diff --name-only" and "git show --format= --name-only HEAD" to catch changed files - set require_serial for this patch to prohibit multiple git commands during pre-commit run which otherwise uses multiprocessing by default Change-Id: I8027ba6d3cf9e3824312dddc9488f3620799f170 --- .pre-commit-config.yaml | 1 + tests/hooks/copyright_fixer.py | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a8da2f5ed1..0b74aacbb4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,6 +63,7 @@ repos: entry: tests/hooks/copyright_fixer.py files: .+\.py$ language: python + require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.12.2 hooks: diff --git a/tests/hooks/copyright_fixer.py b/tests/hooks/copyright_fixer.py index 93eeb8167b..f72519009f 100755 --- a/tests/hooks/copyright_fixer.py +++ b/tests/hooks/copyright_fixer.py @@ -22,10 +22,21 @@ def get_patched_files(): """Return the PatchSet for the latest commit.""" - out = subprocess.run(['git', 'diff', '--unified=0'], - stdout=subprocess.PIPE, - check=True, encoding='utf-8', text=True).stdout - return {Path(path) for path in re.findall(r'(?m)^\+\+\+ b/(.+)$', out) + cmd_opts = ' --name-only --diff-filter=AMR' + diff_cmd = f'git diff {cmd_opts}'.split() + show_cmd = f'git show --format= {cmd_opts}'.split() + + captures = [] + captures.append( + subprocess.check_output(diff_cmd, encoding='utf-8') + ) + captures.append( + subprocess.check_output(diff_cmd + ['--staged'], encoding='utf-8') + ) + captures.append( + subprocess.check_output(show_cmd + ['HEAD'], encoding='utf-8') + ) + return {Path(path) for capture in captures for path in capture.splitlines() if path.endswith('.py')} From 8bc40adc043c6bce18bce0d89506c40c8951d3f6 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 7 Jul 2025 13:57:57 +0200 Subject: [PATCH 007/279] [tests] make mypy tests mandatory for some files - some files passes the mypy typing linter and they should pass the tests now with pre-commit tests. Therefore add mirrors-mypy to pre-commit tests and specify the files to be tested - the non-voting test is kept for the remaining files. Therefore use conftest.py to exclude the files tested by pre-commit - use Python 3.9 for mypy tests Bug: T398947 Change-Id: Ifbe70e9e256710de1e3f4029ae556ed9cd03f6ba --- .pre-commit-config.yaml | 19 +++++++++++++++ CONTENT.rst | 2 ++ conftest.py | 52 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tox.ini | 4 +++- 5 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 conftest.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b74aacbb4..b64ec4640e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -104,6 +104,7 @@ repos: - id: flake8 args: - --doctests + - --config=tox.ini additional_dependencies: # Due to incompatibilities between packages the order matters. - flake8-bugbear>=24.12.12 @@ -111,3 +112,21 @@ repos: - flake8-print>=5.0.0 - flake8-tuple>=0.4.1 - pep8-naming>=0.15.1 + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.16.1 + hooks: + - id: mypy + args: + - --config-file=pyproject.toml + - --follow-imports=silent + # Test for files which already passed in past. + # They should be also used in conftest.py to exclude them from non-voting mypy test. + files: > + ^pywikibot/(__metadata__|exceptions|fixes|time)\.py$| + ^pywikibot/(comms|data|families|specialbots)/__init__\.py$| + ^pywikibot/families/[a-z]+_family\.py$| + ^pywikibot/page/(__init__|_decorators|_revision)\.py$| + ^pywikibot/scripts/(?:i18n/)?__init__\.py$| + ^pywikibot/site/(__init__|_basesite|_decorators|_interwikimap|_upload)\.py$| + ^pywikibot/tools/(_logging|_unidata|formatter)\.py$| + ^pywikibot/userinterfaces/(__init__|_interface_base|terminal_interface)\.py$ diff --git a/CONTENT.rst b/CONTENT.rst index 50936affff..5ffc998160 100644 --- a/CONTENT.rst +++ b/CONTENT.rst @@ -24,6 +24,8 @@ The contents of the package +---------------------------+-----------------------------------------------------------+ | ROADMAP.rst | PyPI version roadmap file | +---------------------------+-----------------------------------------------------------+ + | conftest.py | Local per-directory plugin for pytest-mypy | + +---------------------------+-----------------------------------------------------------+ | dev-requirements.txt | PIP requirements file for development dependencies | +---------------------------+-----------------------------------------------------------+ | make_dist.py | Script to create a Pywikibot distribution | diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000000..80131026b2 --- /dev/null +++ b/conftest.py @@ -0,0 +1,52 @@ +"""Configuration file for pytest.""" +# +# (C) Pywikibot team, 2025 +# +# Distributed under the terms of the MIT license. +# +from __future__ import annotations + +import re +from pathlib import Path +from typing import Literal + + +EXCLUDE_PATTERN = re.compile( + r'(?:' + r'(__metadata__|exceptions|fixes|time)|' + r'(comms|data|families|specialbots)/__init__|' + r'families/[a-z]+_family|' + r'page/(__init__|_decorators|_revision)|' + r'scripts/(i18n/)?__init__|' + r'site/(__init__|_basesite|_decorators|_interwikimap|_upload)|' + r'tools/(_logging|_unidata|formatter)|' + r'userinterfaces/(__init__|_interface_base|terminal_interface)' + r')\.py' +) + + +def pytest_ignore_collect(collection_path: Path, + config) -> Literal[True] | None: + """Ignore files matching EXCLUDE_PATTERN when pytest-mypy is loaded. + + .. versionadded:: 10.3 + """ + # Check if any plugin name includes 'mypy' + plugin_names = {p.__class__.__name__.lower() + for p in config.pluginmanager.get_plugins()} + if not any('mypy' in name for name in plugin_names): + return None + + project_root = Path(__file__).parent / 'pywikibot' + try: + rel_path = collection_path.relative_to(project_root) + except ValueError: + # Ignore files outside project root + return None + + norm_path = rel_path.as_posix() + if EXCLUDE_PATTERN.fullmatch(norm_path): + print(f'Ignoring file in mypy: {norm_path}') # noqa: T201 + return True + + return None diff --git a/pyproject.toml b/pyproject.toml index cea7b2762e..8ff8cc9152 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,7 +177,7 @@ use_parentheses = true [tool.mypy] -python_version = 3.8 +python_version = 3.9 enable_error_code = [ "ignore-without-code", ] diff --git a/tox.ini b/tox.ini index 0c859e37ef..6df5ee2e4a 100644 --- a/tox.ini +++ b/tox.ini @@ -61,7 +61,7 @@ deps = deeptest-py312: pytest-subtests != 0.14.0 [testenv:typing] -basepython = python3.8 +basepython = python3.9 deps = pytest-mypy commands = mypy --version @@ -222,9 +222,11 @@ ignore_regex=:keyword # pep8-naming classmethod-decorators = classmethod,classproperty + [pycodestyle] exclude = .tox,.git,./*.egg,build,./scripts/i18n/* + [pytest] minversion = 7.0.1 testpaths = tests From 92fa3b4dca30bd5528ab008165ff25fe46e36bea Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 23 Jun 2025 09:45:17 +0200 Subject: [PATCH 008/279] IMPR: Use an IntEnum for standard file descriptors instead of integers Also fix utf-8 encoding Change-Id: Ib00466f4b56e9f8335e5ae2fc046c6339b4f62ae --- pywikibot/daemonize.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/pywikibot/daemonize.py b/pywikibot/daemonize.py index 9b8df1de9f..ea90c16a99 100644 --- a/pywikibot/daemonize.py +++ b/pywikibot/daemonize.py @@ -1,6 +1,6 @@ """Module to daemonize the current process on Unix.""" # -# (C) Pywikibot team, 2007-2022 +# (C) Pywikibot team, 2007-2025 # # Distributed under the terms of the MIT license. # @@ -9,9 +9,19 @@ import os import stat import sys +from enum import IntEnum from pathlib import Path +class StandardFD(IntEnum): + + """File descriptors for standard input, output and error.""" + + STDIN = 0 + STDOUT = 1 + STDERR = 2 + + is_daemon = False @@ -35,15 +45,16 @@ def daemonize(close_fd: bool = True, # Fork again to prevent the process from acquiring a # controlling terminal pid = os.fork() + if not pid: global is_daemon is_daemon = True if close_fd: - os.close(0) - os.close(1) - os.close(2) + for fd in StandardFD: + os.close(fd) os.open('/dev/null', os.O_RDWR) + if redirect_std: # R/W mode without execute flags mode = (stat.S_IRUSR | stat.S_IWUSR @@ -53,15 +64,16 @@ def daemonize(close_fd: bool = True, os.O_WRONLY | os.O_APPEND | os.O_CREAT, mode) else: - os.dup2(0, 1) - os.dup2(1, 2) + os.dup2(StandardFD.STDIN, StandardFD.STDOUT) + os.dup2(StandardFD.STDOUT, StandardFD.STDERR) + if chdir: os.chdir('/') return # Write out the pid path = Path(Path(sys.argv[0]).name).with_suffix('.pid') - path.write_text(str(pid), encoding='uft-8') + path.write_text(str(pid), encoding='utf-8') # Exit to return control to the terminal # os._exit to prevent the cleanup to run From 304f9e79710f061f7d1167cfd8db5edbeec751b8 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 8 Jul 2025 14:55:18 +0200 Subject: [PATCH 009/279] [doc] update ROADMAP.rst and other document files Change-Id: I1e0c65d0bee66968b05ddbba4fd2a09a85efc91e --- ROADMAP.rst | 6 +++++- conftest.py | 10 +++++----- docs/tests_ref/index.rst | 2 ++ docs/tests_ref/precommit.rst | 8 ++++++++ docs/tests_ref/pytest.rst | 8 ++++++++ tests/hooks/__init__.py | 9 ++++++++- tests/hooks/copyright_fixer.py | 5 ++++- 7 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 docs/tests_ref/precommit.rst create mode 100644 docs/tests_ref/pytest.rst diff --git a/ROADMAP.rst b/ROADMAP.rst index fb6a0e942d..26bbc03422 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,9 +1,11 @@ Current Release Changes ======================= +* ``textlib.to_latin_digits()`` was renamed to :func:`textlib.to_ascii_digits` (:phab:`T398146#10958283`), + ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` was renamed to ``NON_ASCII_DIGITS``. * Add -cookies option to :mod:`login` script to login with cookies files only * Create a Site using :func:`pywikibot.Site` constructor with a given url even if the url ends with - a slash (:phab:`T396592`) + a slash (:phab:`T396592`) * Remove hard-coded error messages from :meth:`login.LoginManager.login` and use API response instead * Add additional informations to :meth:`Site.login()` error message (:phab:`T395670`) * i18n updates @@ -11,6 +13,8 @@ Current Release Changes Current Deprecations ==================== +* 10.3.0: ``textlib.to_latin_digits()`` will be removed in favour of :func:`textlib.to_ascii_digits`, + ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` will be removed in favour of ``NON_ASCII_DIGITS``. * 10.2.0: :mod:`tools.threading.RLock` is deprecated and moved to :mod:`backports` module. The :meth:`backports.RLock.count` method is also deprecated. For Python 3.14+ use ``RLock`` from Python library ``threading`` instead. (:phab:`T395182`) diff --git a/conftest.py b/conftest.py index 80131026b2..787b6c5a8c 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,7 @@ -"""Configuration file for pytest.""" +"""Configuration file for pytest. + +.. versionadded:: 10.3 +""" # # (C) Pywikibot team, 2025 # @@ -27,10 +30,7 @@ def pytest_ignore_collect(collection_path: Path, config) -> Literal[True] | None: - """Ignore files matching EXCLUDE_PATTERN when pytest-mypy is loaded. - - .. versionadded:: 10.3 - """ + """Ignore files matching EXCLUDE_PATTERN when pytest-mypy is loaded.""" # Check if any plugin name includes 'mypy' plugin_names = {p.__class__.__name__.lower() for p in config.pluginmanager.get_plugins()} diff --git a/docs/tests_ref/index.rst b/docs/tests_ref/index.rst index 4c782e372a..2d4eefce44 100644 --- a/docs/tests_ref/index.rst +++ b/docs/tests_ref/index.rst @@ -15,3 +15,5 @@ Test utilities aspects basepage utils + precommit + pytest diff --git a/docs/tests_ref/precommit.rst b/docs/tests_ref/precommit.rst new file mode 100644 index 0000000000..1fb5769b58 --- /dev/null +++ b/docs/tests_ref/precommit.rst @@ -0,0 +1,8 @@ +********************** +precommit hooks module +********************** + +.. automodule:: tests.hooks.copyright_fixer + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/tests_ref/pytest.rst b/docs/tests_ref/pytest.rst new file mode 100644 index 0000000000..5a3d69ec9c --- /dev/null +++ b/docs/tests_ref/pytest.rst @@ -0,0 +1,8 @@ +************************* +pytest mypy plugin module +************************* + +.. automodule:: conftest + :members: + :undoc-members: + :show-inheritance: diff --git a/tests/hooks/__init__.py b/tests/hooks/__init__.py index 8a22a45b51..b9e3b06568 100644 --- a/tests/hooks/__init__.py +++ b/tests/hooks/__init__.py @@ -1,3 +1,10 @@ -"""Local pre-commit hooks for CI tests.""" +"""Local pre-commit hooks for CI tests. +.. versionadded:: 10.3 +""" +# +# (C) Pywikibot team, 2025 +# +# Distributed under the terms of the MIT license. +# from __future__ import annotations diff --git a/tests/hooks/copyright_fixer.py b/tests/hooks/copyright_fixer.py index f72519009f..d3fa734884 100755 --- a/tests/hooks/copyright_fixer.py +++ b/tests/hooks/copyright_fixer.py @@ -1,5 +1,8 @@ #!/usr/bin/env python -"""Pre-commit hook to set the leftmost copyright year.""" +"""Pre-commit hook to set the leftmost copyright year. + +.. versionadded:: 10.3 +""" # # (C) Pywikibot team, 2025 # From b0372fbaea497f18544c2197ba50f1af989e79a7 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 9 Jul 2025 08:36:18 +0200 Subject: [PATCH 010/279] tests: Skip copyright test on github action pre-commit Bug: T399059 Change-Id: I2e289e972d6c7fd2e949ad44ce5b9da876584ab2 --- .github/workflows/pre-commit.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index cc99546c98..45b9a38355 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -42,5 +42,7 @@ jobs: submodules: true - name: run pre-commit uses: pre-commit/action@v3.0.1 + env: + SKIP: copyright timeout-minutes: 5 timeout-minutes: 100 From ce53bef8b76bb3577132b0a5744c36e61162ac6f Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 9 Jul 2025 10:46:03 +0200 Subject: [PATCH 011/279] IMPR: remove duplicate logic in add_claims Change-Id: Icdf8b7bfd9cef60bef77b5ecf1ac7b816c31703b --- scripts/create_isbn_edition.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scripts/create_isbn_edition.py b/scripts/create_isbn_edition.py index a042a9938a..98bd02d424 100755 --- a/scripts/create_isbn_edition.py +++ b/scripts/create_isbn_edition.py @@ -967,11 +967,6 @@ def add_claims(isbn_data: dict[str, Any]) -> int: # noqa: C901 # Redundant "subtitles" are ignored subtitle = first_upper(titles[1].strip()) - # Get formatted ISBN number - isbn_number = isbn_data['ISBN-13'] # Numeric format - isbn_fmtd = isbnlib.mask(isbn_number) # Canonical format (with "-") - pywikibot.log(isbn_fmtd) - # Search the ISBN number both in canonical and numeric format qnumber_list = get_item_with_prop_value(ISBNPROP, isbn_fmtd) qnumber_list.update(get_item_with_prop_value(ISBNPROP, isbn_number)) From e78c7c052d13959b00e515eef0004ec3db10a6ef Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 9 Jul 2025 14:36:52 +0200 Subject: [PATCH 012/279] tests: Proceed Login CI tests wit all other actions are completed Change-Id: Ifaab6092bb5037ef177232d36006a57b1b3d40ad --- .github/workflows/login_tests-ci.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index f167462166..f4582f0561 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -4,10 +4,15 @@ name: Login CI on: workflow_run: - workflows: [Pywikibot CI] + workflows: + - Doctest CI + - Oauth CI + - pre-commit + - Pywikibot CI + - Sysop write test CI + - Windows Tests branches: [master] - types: - - completed + types: [completed] env: PYWIKIBOT_TEST_RUNNING: 1 @@ -15,8 +20,16 @@ env: PYWIKIBOT_USERNAME: Pywikibot-test jobs: - build: + wait_for_all: + runs-on: ubuntu-latest + steps: + - name: Wait for all workflows to complete + uses: ahmadnassri/action-workflow-run-wait@v1.4.4 + - name: Proceed with tests + run: echo "All workflows have completed. Proceeding with Login CI tests." + run_tests: runs-on: ${{ matrix.os || 'ubuntu-latest' }} + needs: wait_for_all timeout-minutes: 30 strategy: fail-fast: false From f3dc82ac5894f26f1cf04c80e27101f22b498a1a Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 9 Jul 2025 15:02:35 +0200 Subject: [PATCH 013/279] tests: Update timeout for Login CI wait cycles Change-Id: Ia99fd5d051e9442ad2965493634ef5ae7b818566 --- .github/workflows/login_tests-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index f4582f0561..e1299081d1 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -25,6 +25,9 @@ jobs: steps: - name: Wait for all workflows to complete uses: ahmadnassri/action-workflow-run-wait@v1.4.4 + with: + delay: 60000 + timeout: 3600000 - name: Proceed with tests run: echo "All workflows have completed. Proceeding with Login CI tests." run_tests: From 858ae1fbf72cf4aeb660b10a39b69bafb7010130 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 9 Jul 2025 15:36:53 +0200 Subject: [PATCH 014/279] tests: use kachick/wait-other-jobs for the wait cycle This action is more flexible and still maintained Change-Id: I0a3bad561225fa001e0d56577d2c9219db29a25c --- .github/workflows/login_tests-ci.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index e1299081d1..b4b5a815a7 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -4,13 +4,7 @@ name: Login CI on: workflow_run: - workflows: - - Doctest CI - - Oauth CI - - pre-commit - - Pywikibot CI - - Sysop write test CI - - Windows Tests + workflows: [Pywikibot CI] branches: [master] types: [completed] @@ -24,10 +18,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Wait for all workflows to complete - uses: ahmadnassri/action-workflow-run-wait@v1.4.4 + uses: kachick/wait-other-jobs@v3.8.1 with: - delay: 60000 - timeout: 3600000 + warmup-delay: PT1M + minimum-interval: PT5M - name: Proceed with tests run: echo "All workflows have completed. Proceeding with Login CI tests." run_tests: From d13df99ef5fe16bb3c9050a3a5fbde311b64980d Mon Sep 17 00:00:00 2001 From: JJMC89 Date: Thu, 10 Jul 2025 20:16:29 -0700 Subject: [PATCH 015/279] update beta.wmflabs.org to beta.wmcloud.org Change-Id: I9a5b82c5d183686ec79ad24bd179309e73ade887 --- .github/workflows/login_tests-ci.yml | 4 ++-- .github/workflows/oauth_tests-ci.yml | 6 +++--- .github/workflows/pywikibot-ci.yml | 4 ++-- pywikibot/families/commons_family.py | 4 ++-- pywikibot/families/wikidata_family.py | 4 ++-- pywikibot/families/wikisource_family.py | 2 +- tests/file_tests.py | 2 +- tests/http_tests.py | 20 ++++++++++---------- tests/site_tests.py | 2 +- tests/utils.py | 2 +- 10 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index b4b5a815a7..66e06ab767 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -90,10 +90,10 @@ jobs: - name: Generate family files run: | if [ ${{matrix.family || 0}} == wpbeta ]; then - python pwb.py generate_family_file http://${{matrix.code}}.wikipedia.beta.wmflabs.org/ wpbeta y + python pwb.py generate_family_file http://${{matrix.code}}.wikipedia.beta.wmcloud.org/ wpbeta y fi if [ ${{matrix.site || 0}} == 'wsbeta:en' ]; then - python pwb.py generate_family_file http://en.wikisource.beta.wmflabs.org/ wsbeta y + python pwb.py generate_family_file http://en.wikisource.beta.wmcloud.org/ wsbeta y fi - name: Generate user files run: | diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 8fe4cd8321..e4dc52673b 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -30,11 +30,11 @@ jobs: - python-version: '3.8' family: wpbeta code: en - domain: en.wikipedia.beta.wmflabs.org + domain: en.wikipedia.beta.wmcloud.org - python-version: '3.8' family: wpbeta code: zh - domain: zh.wikipedia.beta.wmflabs.org + domain: zh.wikipedia.beta.wmcloud.org steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -82,7 +82,7 @@ jobs: - name: Generate family files if: ${{ matrix.family == 'wpbeta' }} run: | - python pwb.py generate_family_file http://${{matrix.code}}.wikipedia.beta.wmflabs.org/ wpbeta y + python pwb.py generate_family_file http://${{matrix.code}}.wikipedia.beta.wmcloud.org/ wpbeta y - name: Generate user files run: | python -Werror::UserWarning -m pwb generate_user_files -family:${{matrix.family}} -lang:${{matrix.code}} -user:${{ env.PYWIKIBOT_USERNAME }} -v -debug; diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 6130c339ed..e36bcc55af 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -101,10 +101,10 @@ jobs: - name: Generate family files run: | if [ ${{matrix.family || 0}} == wpbeta ]; then - python pwb.py generate_family_file http://${{matrix.code}}.wikipedia.beta.wmflabs.org/ wpbeta y + python pwb.py generate_family_file http://${{matrix.code}}.wikipedia.beta.wmcloud.org/ wpbeta y fi if [ ${{matrix.site || 0}} == 'wsbeta:en' ]; then - python pwb.py generate_family_file http://en.wikisource.beta.wmflabs.org/ wsbeta y + python pwb.py generate_family_file http://en.wikisource.beta.wmcloud.org/ wsbeta y fi - name: Generate user files run: | diff --git a/pywikibot/families/commons_family.py b/pywikibot/families/commons_family.py index 30773bda87..527ce55b58 100644 --- a/pywikibot/families/commons_family.py +++ b/pywikibot/families/commons_family.py @@ -1,6 +1,6 @@ """Family module for Wikimedia Commons.""" # -# (C) Pywikibot team, 2005-2023 +# (C) Pywikibot team, 2005-2025 # # Distributed under the terms of the MIT license. # @@ -24,7 +24,7 @@ class Family(family.WikimediaFamily, family.DefaultWikibaseFamily): langs = { 'commons': 'commons.wikimedia.org', 'test': 'test-commons.wikimedia.org', - 'beta': 'commons.wikimedia.beta.wmflabs.org' + 'beta': 'commons.wikimedia.beta.wmcloud.org' } # Sites we want to edit but not count as real languages diff --git a/pywikibot/families/wikidata_family.py b/pywikibot/families/wikidata_family.py index 89b79faa33..329b567da3 100644 --- a/pywikibot/families/wikidata_family.py +++ b/pywikibot/families/wikidata_family.py @@ -1,6 +1,6 @@ """Family module for Wikidata.""" # -# (C) Pywikibot team, 2012-2023 +# (C) Pywikibot team, 2012-2025 # # Distributed under the terms of the MIT license. # @@ -18,7 +18,7 @@ class Family(family.WikimediaFamily, family.DefaultWikibaseFamily): langs = { 'wikidata': 'www.wikidata.org', 'test': 'test.wikidata.org', - 'beta': 'wikidata.beta.wmflabs.org', + 'beta': 'wikidata.beta.wmcloud.org', } # Sites we want to edit but not count as real languages diff --git a/pywikibot/families/wikisource_family.py b/pywikibot/families/wikisource_family.py index 09802e2c7d..d5ea0eef08 100644 --- a/pywikibot/families/wikisource_family.py +++ b/pywikibot/families/wikisource_family.py @@ -56,7 +56,7 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): def langs(cls): cls.langs = super().langs cls.langs['mul'] = cls.domain - cls.langs['beta'] = 'en.wikisource.beta.wmflabs.org' + cls.langs['beta'] = 'en.wikisource.beta.wmcloud.org' return cls.langs # Need to explicitly inject the beta domain diff --git a/tests/file_tests.py b/tests/file_tests.py index d317627d63..ebb6f7d1ab 100755 --- a/tests/file_tests.py +++ b/tests/file_tests.py @@ -457,7 +457,7 @@ class TestMediaInfoEditing(TestCase): login = True write = True - # commons.wikimedia.beta.wmflabs.org + # commons.wikimedia.beta.wmcloud.org family = 'commons' code = 'beta' diff --git a/tests/http_tests.py b/tests/http_tests.py index 317a8b254b..a07920625f 100755 --- a/tests/http_tests.py +++ b/tests/http_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for http module.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -55,10 +55,10 @@ def setUp(self) -> None: super().setUp() self._authenticate = config.authenticate config.authenticate = { - 'zh.wikipedia.beta.wmflabs.org': ('1', '2'), - '*.wikipedia.beta.wmflabs.org': ('3', '4', '3', '4'), - '*.beta.wmflabs.org': ('5', '6'), - '*.wmflabs.org': ('7', '8', '8'), + 'zh.wikipedia.beta.wmcloud.org': ('1', '2'), + '*.wikipedia.beta.wmcloud.org': ('3', '4', '3', '4'), + '*.beta.wmcloud.org': ('5', '6'), + '*.wmcloud.org': ('7', '8', '8'), } def tearDown(self) -> None: @@ -69,11 +69,11 @@ def tearDown(self) -> None: def test_url_based_authentication(self) -> None: """Test url-based authentication info.""" pairs = { - 'https://zh.wikipedia.beta.wmflabs.org': ('1', '2'), - 'https://en.wikipedia.beta.wmflabs.org': ('3', '4', '3', '4'), - 'https://wiki.beta.wmflabs.org': ('5', '6'), - 'https://beta.wmflabs.org': None, - 'https://wmflabs.org': None, + 'https://zh.wikipedia.beta.wmcloud.org': ('1', '2'), + 'https://en.wikipedia.beta.wmcloud.org': ('3', '4', '3', '4'), + 'https://wiki.beta.wmcloud.org': ('5', '6'), + 'https://beta.wmcloud.org': None, + 'https://wmcloud.org': None, 'https://www.wikiquote.org/': None, } with suppress_warnings( diff --git a/tests/site_tests.py b/tests/site_tests.py index 8b66149a22..d62f266ebb 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -1131,7 +1131,7 @@ def test_commons(self) -> None: site2 = pywikibot.Site('beta') self.assertEqual(site2.hostname(), - 'commons.wikimedia.beta.wmflabs.org') + 'commons.wikimedia.beta.wmcloud.org') self.assertEqual(site2.code, 'beta') self.assertFalse(site2.obsolete) diff --git a/tests/utils.py b/tests/utils.py index 5bf389eda4..8f5d342dba 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -387,7 +387,7 @@ def image_repository(self): def data_repository(self): """Return Site object for data repository e.g. Wikidata.""" - if self.hostname().endswith('.beta.wmflabs.org'): + if self.hostname().endswith('.beta.wmcloud.org'): # TODO: Use definition for beta cluster's wikidata code, fam = None, None fam_name = self.hostname().split('.')[-4] From 09255ac0f0ec283548e4e85f183418e88a474625 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 11 Jul 2025 09:15:28 +0200 Subject: [PATCH 016/279] doc: Expand thottle module description Change-Id: I83853c764767c30c040bc766e6916a5e541430cf --- pywikibot/throttle.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pywikibot/throttle.py b/pywikibot/throttle.py index 26d38d3eec..d35960f33c 100644 --- a/pywikibot/throttle.py +++ b/pywikibot/throttle.py @@ -1,6 +1,16 @@ -"""Mechanics to slow down wiki read and/or write rate.""" +"""Mechanisms to regulate the read and write rate to wiki servers. + +This module defines the :class:`Throttle` class, which ensures that +automated access to wiki servers adheres to responsible rate limits. It +avoids overloading the servers by introducing configurable delays +between requests, and coordinates these limits across processes using a +shared control file ``throttle.ctrl``. + +It supports both read and write throttling, automatic adjustment based +on the number of concurrent bot instances, and optional lag-aware delays. +""" # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # From 05f979ac880f7821ee3f4d17e1d1011630909aa1 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 11 Jul 2025 17:51:37 +0200 Subject: [PATCH 017/279] update .wmflabs.org to .wmcloud.org Follow-up patch for d13df99 Change-Id: I428c3b01754cc36fa9f2a9845f146285b31c30f6 --- tests/http_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http_tests.py b/tests/http_tests.py index a07920625f..0ee2e0c25e 100755 --- a/tests/http_tests.py +++ b/tests/http_tests.py @@ -77,7 +77,7 @@ def test_url_based_authentication(self) -> None: 'https://www.wikiquote.org/': None, } with suppress_warnings( - r"config.authenticate\['\*.wmflabs.org'] has invalid value.", + r"config.authenticate\['\*.wmcloud.org'] has invalid value.", UserWarning, ): for url, auth in pairs.items(): From 4841aa615c14e98b0425c14fd4f0c48266edbfc4 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 12 Jul 2025 12:39:06 +0200 Subject: [PATCH 018/279] Tests: Increase timeout-minutes for oauth_tests-ci Bug: T399359 Change-Id: Ibbf8f553c54970498381471cae6664bb23870745 --- .github/workflows/oauth_tests-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index e4dc52673b..f38893a320 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -18,7 +18,7 @@ jobs: build: runs-on: ${{ matrix.os || 'ubuntu-latest' }} continue-on-error: ${{ matrix.experimental || false }} - timeout-minutes: 5 + timeout-minutes: 6 strategy: fail-fast: false matrix: @@ -92,7 +92,7 @@ jobs: echo "maximum_GET_length = 5000" >> user-config.py echo "console_encoding = 'utf8'" >> user-config.py - name: Oauth tests with unittest - timeout-minutes: 2 + timeout-minutes: 4 env: PYWIKIBOT_TEST_WRITE: 1 PYWIKIBOT_TEST_OAUTH: ${{ secrets[format('{0}', steps.token.outputs.uppercase)] }} From 8eb3e0b807f58baca5471a09ed662033556b2e9e Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 11 Jul 2025 17:41:00 +0200 Subject: [PATCH 019/279] tests: Ignore code parts from coverage Change-Id: I236eff331b5bbc48034b57288a0d488775fed9bb --- conftest.py | 2 ++ make_dist.py | 8 ++++---- pyproject.toml | 7 +++---- pywikibot/scripts/__init__.py | 4 ++-- pywikibot/scripts/generate_family_file.py | 2 +- pywikibot/scripts/generate_user_files.py | 5 +++-- pywikibot/scripts/shell.py | 4 ++-- 7 files changed, 17 insertions(+), 15 deletions(-) diff --git a/conftest.py b/conftest.py index 787b6c5a8c..144871717f 100644 --- a/conftest.py +++ b/conftest.py @@ -37,6 +37,7 @@ def pytest_ignore_collect(collection_path: Path, if not any('mypy' in name for name in plugin_names): return None + # no cover: start project_root = Path(__file__).parent / 'pywikibot' try: rel_path = collection_path.relative_to(project_root) @@ -50,3 +51,4 @@ def pytest_ignore_collect(collection_path: Path, return True return None + # no cover: stop diff --git a/make_dist.py b/make_dist.py index 72dfea631a..c2a519e6cd 100755 --- a/make_dist.py +++ b/make_dist.py @@ -52,7 +52,7 @@ The pywikibot-scripts distribution can be created. """ # -# (C) Pywikibot team, 2022-2024 +# (C) Pywikibot team, 2022-2025 # # Distributed under the terms of the MIT license. # @@ -228,7 +228,7 @@ class SetupScripts(SetupBase): package = 'pywikibot_scripts' replace = 'MANIFEST.in', 'pyproject.toml', 'setup.py' - def copy_files(self) -> None: + def copy_files(self) -> None: # pragma: no cover """Ignore copy files yet.""" info('<>Copy files ...', newline=False) for filename in self.replace: @@ -238,7 +238,7 @@ def copy_files(self) -> None: shutil.copy(self.folder / 'scripts' / filename, self.folder) info('<>done') - def cleanup(self) -> None: + def cleanup(self) -> None: # pragma: no cover """Ignore cleanup yet.""" info('<>Copy files ...', newline=False) for filename in self.replace: @@ -253,7 +253,7 @@ def handle_args() -> tuple[bool, bool, bool, bool]: :return: Return whether dist is to be installed locally or to be uploaded """ - if '-help' in sys.argv: + if '-help' in sys.argv: # pragma: no cover import re import setup diff --git a/pyproject.toml b/pyproject.toml index 8ff8cc9152..4520211573 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,9 +123,8 @@ Tracker = "https://phabricator.wikimedia.org/tag/pywikibot/" ignore_errors = true skip_empty = true -exclude_lines = [ +exclude_other = [ # Have to re-enable the standard pragma - "pragma: no cover", "except ImportError", "except KeyboardInterrupt", "except OSError", @@ -135,13 +134,13 @@ exclude_lines = [ "raise NotImplementedError", "raise unittest\\.SkipTest", "self\\.skipTest", - "if __name__ == '__main__':", + "if __name__ == .__main__.:", "if .+PYWIKIBOT_TEST_\\w+.+:", "if self\\.mw_version < .+:", - "if TYPE_CHECKING:", "@(abc\\.)?abstractmethod", "@deprecated\\([^\\)]+\\)", "@unittest\\.skip", + "no cover: start(?s:.)*?no cover: stop", ] exclude_also = [ diff --git a/pywikibot/scripts/__init__.py b/pywikibot/scripts/__init__.py index 73a1e556de..87676573a1 100644 --- a/pywikibot/scripts/__init__.py +++ b/pywikibot/scripts/__init__.py @@ -5,7 +5,7 @@ ``preload_sites`` script was removed (:phab:`T348925`). """ # -# (C) Pywikibot team, 2021-2022 +# (C) Pywikibot team, 2021-2025 # # Distributed under the terms of the MIT license. # @@ -27,6 +27,6 @@ def _import_with_no_user_config(*import_args): # Reset this flag if not orig_no_user_config: del environ['PYWIKIBOT_NO_USER_CONFIG'] - else: + else: # pragma: no cover environ['PYWIKIBOT_NO_USER_CONFIG'] = orig_no_user_config return result diff --git a/pywikibot/scripts/generate_family_file.py b/pywikibot/scripts/generate_family_file.py index 3c97c3d646..4e3bde05e6 100755 --- a/pywikibot/scripts/generate_family_file.py +++ b/pywikibot/scripts/generate_family_file.py @@ -253,7 +253,7 @@ def getapis(self) -> None: for lang in remove: self.langs.remove(lang) - def writefile(self, verify) -> None: + def writefile(self, verify) -> None: # pragma: no cover """Write the family file.""" fp = Path(self.base_dir, 'families', f'{self.name}_family.py') print(f'Writing {fp}... ') diff --git a/pywikibot/scripts/generate_user_files.py b/pywikibot/scripts/generate_user_files.py index f4d1612637..9d96aef0b7 100755 --- a/pywikibot/scripts/generate_user_files.py +++ b/pywikibot/scripts/generate_user_files.py @@ -350,7 +350,7 @@ def create_user_config( main_code: str, main_username: str, force: bool = False -) -> None: +) -> None: # pragma: no cover """Create a user-config.py in base_dir. Create a user-password.py if necessary. @@ -439,7 +439,8 @@ def create_user_config( save_botpasswords(botpasswords, f_pass) -def save_botpasswords(botpasswords: str, path: Path) -> None: +def save_botpasswords(botpasswords: str, + path: Path) -> None: # pragma: no cover """Write botpasswords to file. :param botpasswords: botpasswords for password file diff --git a/pywikibot/scripts/shell.py b/pywikibot/scripts/shell.py index 2751e3af31..b5e8331cf3 100755 --- a/pywikibot/scripts/shell.py +++ b/pywikibot/scripts/shell.py @@ -16,7 +16,7 @@ .. versionchanged:: 7.0 moved to pywikibot.scripts """ -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -26,7 +26,7 @@ import sys -def main(*args: str) -> None: +def main(*args: str) -> None: # pragma: no cover """Script entry point. .. versionchanged:: 8.2 From e646f1469675fb719a50653bb35c854bb06a4ceb Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 12 Jul 2025 13:13:47 +0200 Subject: [PATCH 020/279] Tests: Update mypy patterns to make i18n_family tests mandatory Change-Id: Ifc60168944340423480c4529b5b8a5bfa3005b97 --- .pre-commit-config.yaml | 2 +- conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b64ec4640e..20473ac4ac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -124,7 +124,7 @@ repos: files: > ^pywikibot/(__metadata__|exceptions|fixes|time)\.py$| ^pywikibot/(comms|data|families|specialbots)/__init__\.py$| - ^pywikibot/families/[a-z]+_family\.py$| + ^pywikibot/families/[a-z][a-z\d]+_family\.py$| ^pywikibot/page/(__init__|_decorators|_revision)\.py$| ^pywikibot/scripts/(?:i18n/)?__init__\.py$| ^pywikibot/site/(__init__|_basesite|_decorators|_interwikimap|_upload)\.py$| diff --git a/conftest.py b/conftest.py index 144871717f..3b00f67690 100644 --- a/conftest.py +++ b/conftest.py @@ -18,7 +18,7 @@ r'(?:' r'(__metadata__|exceptions|fixes|time)|' r'(comms|data|families|specialbots)/__init__|' - r'families/[a-z]+_family|' + r'families/[a-z][a-z\d]+_family|' r'page/(__init__|_decorators|_revision)|' r'scripts/(i18n/)?__init__|' r'site/(__init__|_basesite|_decorators|_interwikimap|_upload)|' From 9eb9264ffd1eff931a42f357243295a81d5e2f62 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 12 Jul 2025 18:21:16 +0200 Subject: [PATCH 021/279] Tests: Update pre-commit hooks Change-Id: I6d3c8f7f2d1a426feee480a7714ea7cea6645887 --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 20473ac4ac..2292fe2a66 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.2 + rev: v0.12.3 hooks: - id: ruff-check alias: ruff @@ -94,7 +94,7 @@ repos: - id: isort exclude: ^pwb\.py$ - repo: https://github.com/jshwi/docsig - rev: v0.70.0 + rev: v0.71.0 hooks: - id: docsig exclude: ^(tests|scripts) From 0023e3f3a27986d3478259b961b9017e1adb823c Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 6 Jul 2025 19:02:05 +0200 Subject: [PATCH 022/279] [bugfix] use self.event_id instead of self.id for mark_as_read - id was renamed to event_id in 203a05c and removed in release 7.0.0 but the mark_as_read method wasn't updated accordingly. Use event_id now. - use dataclass for the Notification to enable repr/str method - update documentation - update typing hints - update mypy config Bug: T398770 Change-Id: If5dca1d2d468068ea4f6a962dfa18afa84cb74e3 --- .pre-commit-config.yaml | 4 +-- conftest.py | 4 +-- pywikibot/echo.py | 60 ++++++++++++++++++++++++++++------- pywikibot/site/_extensions.py | 46 ++++++++++++++++++++++----- 4 files changed, 90 insertions(+), 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2292fe2a66..02695fd842 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -122,11 +122,11 @@ repos: # Test for files which already passed in past. # They should be also used in conftest.py to exclude them from non-voting mypy test. files: > - ^pywikibot/(__metadata__|exceptions|fixes|time)\.py$| + ^pywikibot/(__metadata__|echo|exceptions|fixes|time)\.py$| ^pywikibot/(comms|data|families|specialbots)/__init__\.py$| ^pywikibot/families/[a-z][a-z\d]+_family\.py$| ^pywikibot/page/(__init__|_decorators|_revision)\.py$| ^pywikibot/scripts/(?:i18n/)?__init__\.py$| - ^pywikibot/site/(__init__|_basesite|_decorators|_interwikimap|_upload)\.py$| + ^pywikibot/site/(__init__|_basesite|_decorators|_extensions|_interwikimap|_upload)\.py$| ^pywikibot/tools/(_logging|_unidata|formatter)\.py$| ^pywikibot/userinterfaces/(__init__|_interface_base|terminal_interface)\.py$ diff --git a/conftest.py b/conftest.py index 3b00f67690..830c04caba 100644 --- a/conftest.py +++ b/conftest.py @@ -16,12 +16,12 @@ EXCLUDE_PATTERN = re.compile( r'(?:' - r'(__metadata__|exceptions|fixes|time)|' + r'(__metadata__|echo|exceptions|fixes|time)|' r'(comms|data|families|specialbots)/__init__|' r'families/[a-z][a-z\d]+_family|' r'page/(__init__|_decorators|_revision)|' r'scripts/(i18n/)?__init__|' - r'site/(__init__|_basesite|_decorators|_interwikimap|_upload)|' + r'site/(__init__|_basesite|_decorators|_extensions|_interwikimap|_upload)|' r'tools/(_logging|_unidata|formatter)|' r'userinterfaces/(__init__|_interface_base|terminal_interface)' r')\.py' diff --git a/pywikibot/echo.py b/pywikibot/echo.py index bd135ed9ef..2dcd683a5e 100644 --- a/pywikibot/echo.py +++ b/pywikibot/echo.py @@ -1,31 +1,52 @@ """Classes and functions for working with the Echo extension.""" # -# (C) Pywikibot team, 2014-2022 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # from __future__ import annotations +from dataclasses import dataclass from typing import Any import pywikibot +@dataclass(eq=False) class Notification: - """A notification issued by the Echo extension.""" + """A notification issued by the Echo extension. - def __init__(self, site: pywikibot.site.BaseSite) -> None: - """Initialize an empty Notification object.""" - self.site = site + .. versionchanged:: 3.0.20190204 + The ``id`` attribute was renamed to ``event_id``, and its type + changed from ``str`` to ``int``. + .. deprecated:: 3.0.20190204 + The ``id`` attribute was retained temporarily for backward + compatibility, but is deprecated and scheduled for removal. + + .. versionremoved:: 7.0 + The ``id`` attribute was removed. + + .. versionchanged:: 10.3 + The class is now defined using the ``@dataclass`` decorator to + simplify internal initialization and improve maintainability. + """ + + site: pywikibot.site.BaseSite + + def __post_init__(self) -> None: + """Initialize attributes for an empty Notification object. + + .. versionadded: 10.3 + """ self.event_id: int | None = None self.type = None self.category = None - self.timestamp = None - self.page = None - self.agent = None - self.read: bool | None = None + self.timestamp: pywikibot.Timestamp | None = None + self.page: pywikibot.Page | None = None + self.agent: pywikibot.User | None = None + self.read: pywikibot.Timestamp | bool | None = None self.content = None self.revid = None @@ -33,12 +54,19 @@ def __init__(self, site: pywikibot.site.BaseSite) -> None: def fromJSON(cls, # noqa: N802 site: pywikibot.site.BaseSite, data: dict[str, Any]) -> Notification: - """Construct a Notification object from our API's JSON data.""" + """Construct a Notification object from API JSON data. + + :param site: The pywikibot site object. + :param data: The JSON data dictionary representing a + notification. + :return: An instance of Notification. + """ notif = cls(site) notif.event_id = int(data['id']) notif.type = data['type'] notif.category = data['category'] + notif.timestamp = pywikibot.Timestamp.fromtimestampformat( data['timestamp']['mw']) @@ -59,8 +87,16 @@ def fromJSON(cls, # noqa: N802 notif.content = data.get('*') notif.revid = data.get('revid') + return notif def mark_as_read(self) -> bool: - """Mark the notification as read.""" - return self.site.notifications_mark_read(list=self.id) + """Mark the notification as read. + + :return: True if the notification was successfully marked as + read, else False. + """ + if self.event_id is None: + return False + + return self.site.notifications_mark_read(**{'list': self.event_id}) diff --git a/pywikibot/site/_extensions.py b/pywikibot/site/_extensions.py index 66ea1ff20a..e30f1306d5 100644 --- a/pywikibot/site/_extensions.py +++ b/pywikibot/site/_extensions.py @@ -6,6 +6,8 @@ # from __future__ import annotations +from typing import TYPE_CHECKING, Protocol + import pywikibot from pywikibot.data import api from pywikibot.echo import Notification @@ -20,6 +22,34 @@ from pywikibot.tools import merge_unique_dicts +if TYPE_CHECKING: + from pywikibot.site import NamespacesDict + + +class BaseSiteProtocol(Protocol): + _proofread_levels: dict[int, str] + tokens: dict[str, str] + + def _generator(self, *args, **kwargs) -> api.Request: + ... + + def _request(self, **kwargs) -> api.Request: + ... + + def _update_page(self, *args, **kwargs) -> None: + ... + + def encoding(self) -> str: + ... + + @property + def namespaces(self, **kwargs) -> NamespacesDict: + ... + + def simple_request(self, **kwargs) -> api.Request: + ... + + class EchoMixin: """APISite mixin for Echo extension.""" @@ -50,15 +80,13 @@ def notifications(self, **kwargs): for notification in notifications) @need_extension('Echo') - def notifications_mark_read(self, **kwargs) -> bool: + def notifications_mark_read(self: BaseSiteProtocol, **kwargs) -> bool: """Mark selected notifications as read. .. seealso:: :api:`echomarkread` :return: whether the action was successful """ - # TODO: ensure that the 'echomarkread' action - # is supported by the site kwargs = merge_unique_dicts(kwargs, action='echomarkread', token=self.tokens['csrf']) req = self.simple_request(**kwargs) @@ -74,7 +102,7 @@ class ProofreadPageMixin: """APISite mixin for ProofreadPage extension.""" @need_extension('ProofreadPage') - def _cache_proofreadinfo(self, expiry=False) -> None: + def _cache_proofreadinfo(self: BaseSiteProtocol, expiry=False) -> None: """Retrieve proofreadinfo from site and cache response. Applicable only to sites with ProofreadPage extension installed. @@ -142,7 +170,8 @@ def proofread_levels(self): return self._proofread_levels @need_extension('ProofreadPage') - def loadpageurls(self, page: pywikibot.page.BasePage) -> None: + def loadpageurls(self: BaseSiteProtocol, + page: pywikibot.page.BasePage) -> None: """Load URLs from api and store in page attributes. Load URLs to images for a given page in the "Page:" namespace. @@ -169,7 +198,7 @@ class GeoDataMixin: """APISite mixin for GeoData extension.""" @need_extension('GeoData') - def loadcoordinfo(self, page) -> None: + def loadcoordinfo(self: BaseSiteProtocol, page) -> None: """Load [[mw:Extension:GeoData]] info.""" title = page.title(with_section=False) query = self._generator(api.PropertyGenerator, @@ -187,7 +216,7 @@ class PageImagesMixin: """APISite mixin for PageImages extension.""" @need_extension('PageImages') - def loadpageimage(self, page) -> None: + def loadpageimage(self: BaseSiteProtocol, page) -> None: """Load [[mw:Extension:PageImages]] info. :param page: The page for which to obtain the image @@ -374,7 +403,8 @@ class TextExtractsMixin: """ @need_extension('TextExtracts') - def extract(self, page: pywikibot.Page, *, + def extract(self: BaseSiteProtocol, + page: pywikibot.Page, *, chars: int | None = None, sentences: int | None = None, intro: bool = True, From b66f67e81e9e0405f80959080494c312943f7c09 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 9 Jul 2025 10:20:01 +0200 Subject: [PATCH 023/279] IMPR: Refactor Subject.replaceLinks and decrease complexity - Move page checks to _fetch_text helper method Change-Id: I774c86aba6189bf15f366a53f158d65673ac9007 --- scripts/interwiki.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/interwiki.py b/scripts/interwiki.py index e2b5f60e71..06af442e59 100755 --- a/scripts/interwiki.py +++ b/scripts/interwiki.py @@ -1528,16 +1528,27 @@ def process_unlimited(self, new, updated) -> None: except GiveUpOnPage: break - def replaceLinks(self, page, newPages) -> bool: - """Return True if saving was successful.""" + def _fetch_text(self, page: pywikibot.Page) -> str: + """Validate page and load it's content for editing. + + This includes checking for: + - `-localonly` flag and whether the page is the origin + - Section-only pages (pages with `#section`) + - Non-existent pages + - Empty pages + + :param page: The page to check. + :return: The text content of the page if it passes all checks. + :raises SaveError: If the page is not eligible for editing. + """ # In this case only continue on the Page we started with if self.conf.localonly and page != self.origin: raise SaveError('-localonly and page != origin') if page.section(): # This is not a page, but a subpage. Do not edit it. - pywikibot.info( - f'Not editing {page}: not doing interwiki on subpages') + pywikibot.info(f'Not editing {page}: interwiki not done on' + ' subpages (#section)') raise SaveError('Link has a #section') try: @@ -1550,6 +1561,12 @@ def replaceLinks(self, page, newPages) -> bool: pywikibot.info(f'Not editing {page}: page is empty') raise SaveError('Page is empty.') + return pagetext + + def replaceLinks(self, page, newPages) -> bool: + """Return True if saving was successful.""" + pagetext = self._fetch_text(page) + # clone original newPages dictionary, so that we can modify it to the # local page's needs new = newPages.copy() From 5b03cf257374aaa0446865441571c68d1f87df1c Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 13 Jul 2025 16:40:53 +0200 Subject: [PATCH 024/279] textlib: refactor HTML removal logic using GetDataHTML parser Refactored `removeHTMLParts()` to use the `GetDataHTML` parser class. Added support for removing HTML tag content and preserving tag attributes. Preserved backward compatibility and added examples as doctests. - Introduced `removetags` parameter to remove specified tag blocks - Preserved tag attributes for kept tags - Replaced internal logic with a cleaner, reusable HTMLParser subclass - Added comprehensive docstrings and usage examples Bug: T399378 Change-Id: I4c1d99f4d41b74dd080f3b631c8f184f56a6d637 --- pywikibot/textlib.py | 219 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 173 insertions(+), 46 deletions(-) diff --git a/pywikibot/textlib.py b/pywikibot/textlib.py index 5d48567a0d..4937bd046c 100644 --- a/pywikibot/textlib.py +++ b/pywikibot/textlib.py @@ -11,6 +11,7 @@ from collections import OrderedDict from collections.abc import Sequence from contextlib import closing, suppress +from dataclasses import dataclass from html.parser import HTMLParser from typing import NamedTuple @@ -539,82 +540,208 @@ def removeDisabledParts(text: str, return text -def removeHTMLParts(text: str, keeptags: list[str] | None = None) -> str: - """Return text without portions where HTML markup is disabled. +def removeHTMLParts(text: str, + keeptags: list[str] | None = None, + *, + removetags: list[str] | None = None) -> str: + """Remove selected HTML tags, their content, and comments from text. - Parts that can/will be removed are HTML tags and all wiki tags. The - exact set of parts which should NOT be removed can be passed as the - *keeptags* parameter, which defaults to - ``['tt', 'nowiki', 'small', 'sup']``. + This function removes HTML tags and their contents for tags listed + in ``removetags``. Tags specified in ``keeptags`` are preserved + along with their content and markup. This is a wrapper around the + :class:`GetDataHTML` parser class. **Example:** - >>> removeHTMLParts('
Hi all!
') + >>> remove = removeHTMLParts + >>> remove('
Hi all!
') 'Hi all!' + >>> remove('', keeptags=['style']) + '' + >>> remove('Note: This is important!') + 'Note: This is important!' + >>> remove('Note: This is important!', removetags=['a']) + ' This is important!' - .. seealso:: :class:`_GetDataHTML` + .. caution:: Tag names must be given in lowercase. + + .. versionchanged:: 10.3 + The *removetags* parameter was added. Refactored to use + :class:`GetDataHTML` and its ``__call__`` method. tag attributes + will be kept. + + :param text: The input HTML text to clean. + :param keeptags: List of tag names to keep, including their content + and markup. Defaults to :code:`['tt', 'nowiki', 'small', 'sup']` + if None. + :param removetags: List of tag names whose tags and content should + be removed. The tags ca be preserved if listed in *keeptags*. + Defaults to :code:`['style', 'script']` if None. + :return: The cleaned text with specified HTML parts removed. """ - # TODO: try to merge with 'removeDisabledParts()' above into one generic - # function - parser = _GetDataHTML() - if keeptags is None: - keeptags = ['tt', 'nowiki', 'small', 'sup'] - with closing(parser): - parser.keeptags = keeptags - parser.feed(text) - return parser.textdata + return GetDataHTML(keeptags=keeptags, removetags=removetags)(text) + + +@dataclass(init=False, eq=False) +class GetDataHTML(HTMLParser): + + """HTML parser that removes unwanted HTML elements and optionally comments. + + Tags listed in *keeptags* are preserved. Tags listed in *removetags* + are removed entirely along with their content. Optionally strips HTML + comments. Use via the callable interface or in a :code:`with closing(...)` + block. + + .. note:: + The callable interface is preferred because it is simpler and + ensures proper resource management automatically. If using the + context manager, be sure to access :attr:`textdata` before calling + :meth:`close`. + + .. tabs:: + .. tab:: callable interface -class _GetDataHTML(HTMLParser): + .. code-block:: python - """HTML parser which removes html tags except they are listed in keeptags. + text = ('Test' + '

me!

') - The parser is used by :func:`removeHTMLParts` similar to this: + parser = GetDataHTML(keeptags = ['html']) + clean_text = parser(text) - .. code-block:: python + .. tab:: closing block - from contextlib import closing - from pywikibot.textlib import _GetDataHTML - with closing(_GetDataHTML()) as parser: - parser.keeptags = ['html'] - parser.feed('Test' - '

me!

') - print(parser.textdata) + .. code-block:: python - The result is: + from contextlib import closing + text = ('Test' + '

me!

') - .. code-block:: html + parser = GetDataHTML(keeptags = ['html']) + with closing(parser): + parser.feed(text) + clean_text = parser.textdata - Test me! + .. warning:: Save the :attr:`textdata` **before** :meth:`close` + is called; otherwise the cleaned text is empty. + + **Usage:** + + >>> text = ('Test' + ... '

me!

') + >>> GetDataHTML()(text) + 'Test me!' + >>> GetDataHTML(keeptags=['title'])(text) + 'Test me!' + >>> GetDataHTML(removetags=['body'])(text) + 'Test' + + .. caution:: Tag names must be given in lowercase. .. versionchanged:: 9.2 - This class is no longer a context manager; - :pylib:`contextlib.closing()` - should be used instead. + No longer a context manager + + .. versionchanged:: 10.3 + Public class now. Added support for removals of tag contents. .. seealso:: + - :func:`removeHTMLParts` - :pylib:`html.parser` - - :pylib:`contextlib#contextlib.closing` - :meta public: + :param keeptags: List of tag names to keep, including their content + and markup. Defaults to :code:`['tt', 'nowiki', 'small', 'sup']` + if None. + :param removetags: List of tag names whose tags and content should + be removed. The tags can be preserved if listed in *keeptags*. + Defaults to :code:`['style', 'script']` if None. + :param removecomments: Whether to remove HTML comments. Defaults to + True. """ - textdata = '' - keeptags: list[str] = [] + def __init__(self, *, + keeptags: list[str] | None = None, + removetags: list[str] | None = None) -> None: + """Initialize default tags and internal state.""" + super().__init__() + self.keeptags: list[str] = (keeptags if keeptags is not None + else ['tt', 'nowiki', 'small', 'sup']) + self.removetags: list[str] = (removetags if removetags is not None + else ['style', 'script']) + + #: The cleaned output text collected during parsing. + self.textdata = '' + + self._skiptag: str | None = None - def handle_data(self, data) -> None: - """Add data to text.""" - self.textdata += data + def __call__(self, text: str) -> str: + """Feed the parser with *text* and return cleaned :attr:`textdata`. - def handle_starttag(self, tag, attrs) -> None: - """Add start tag to text if tag should be kept.""" + :param text: The HTML text to parse and clean. + :return: The cleaned text with unwanted tags/content removed. + """ + with closing(self): + self.feed(text) + return self.textdata + + def close(self) -> None: + """Clean current processing and clear :attr:`textdata`.""" + self.textdata = '' + self._skiptag = None + super().close() + + def handle_data(self, data: str) -> None: + """Handle plain text content found between tags. + + Text is added to the output unless it is located inside a tag + marked for removal. + + :param data: The text data between HTML tags. + """ + if not self._skiptag: + self.textdata += data + + def handle_starttag(self, + tag: str, + attrs: list[tuple[str, str | None]]) -> None: + """Handle an opening HTML tag. + + Tags listed in *keeptags* are preserved in the output. Tags + listed in *removetags* begin a skip block, and their content + will be excluded from the output. + + .. versionchanged:: 10.3 + Keep tag attributes. + + :param tag: The tag name (e.g., "div", "script") converted to + lowercase. + :param attrs: A list of (name, value) pairs with tag attributes. + """ if tag in self.keeptags: - self.textdata += f'<{tag}>' - def handle_endtag(self, tag) -> None: - """Add end tag to text if tag should be kept.""" + # Reconstruct attributes for preserved tags + attr_text = ''.join( + f' {name}' if value is None else f' {name}="{value}"' + for name, value in attrs + ) + self.textdata += f'<{tag}{attr_text}>' + + if tag in self.removetags: + self._skiptag = tag + + def handle_endtag(self, tag: str) -> None: + """Handle a closing HTML tag. + + Tags listed in *keeptags* are preserved in the output. A closing + tag that matches the currently skipped tag will end the skip + block. + + :param tag: The name of the closing tag. + """ if tag in self.keeptags: self.textdata += f'' + if tag in self.removetags and tag == self._skiptag: + self._skiptag = None def isDisabled(text: str, index: int, tags=None) -> bool: From df9507c377c12129d018590c1553e6b88ec18f67 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 13 Jul 2025 18:32:32 +0200 Subject: [PATCH 025/279] family: fix language alias for gsw -> als Bug: T399411 Change-Id: If523b1653d3390fff398e7254c070f31aab2c4b9 --- pywikibot/family.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pywikibot/family.py b/pywikibot/family.py index d7e9b8e10a..32987e7d4b 100644 --- a/pywikibot/family.py +++ b/pywikibot/family.py @@ -961,8 +961,9 @@ class WikimediaFamily(Family): 'dk': 'da', 'jp': 'ja', - # Language aliases, see T86924 - 'nb': 'no', + # Language aliases + 'nb': 'no', # T86924 + 'gsw': 'als', # T399411 # Closed wiki redirection aliases 'mo': 'ro', From a1a318ef6790322d7dd17082be9508bcd2ca457f Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 13 Jul 2025 18:57:04 +0200 Subject: [PATCH 026/279] Tests: Increase timeout-minutes for oauth_tests-ci Bug: T399359 Change-Id: I711a7a634ee2cf8f0bca93baf648211e574c9e89 --- .github/workflows/oauth_tests-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index f38893a320..0aa7e33c7b 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -18,7 +18,7 @@ jobs: build: runs-on: ${{ matrix.os || 'ubuntu-latest' }} continue-on-error: ${{ matrix.experimental || false }} - timeout-minutes: 6 + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -92,7 +92,7 @@ jobs: echo "maximum_GET_length = 5000" >> user-config.py echo "console_encoding = 'utf8'" >> user-config.py - name: Oauth tests with unittest - timeout-minutes: 4 + timeout-minutes: 8 env: PYWIKIBOT_TEST_WRITE: 1 PYWIKIBOT_TEST_OAUTH: ${{ secrets[format('{0}', steps.token.outputs.uppercase)] }} From 073c71c5ef7fcbe9dc9332e369c6136ed41c3a63 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 13 Jul 2025 21:05:10 +0200 Subject: [PATCH 027/279] Tests: print public IP for oauth_tests-ci runner for debugging Change-Id: Ic7ab756399f0037320a45d3a4533937bc9b05c7b --- .github/workflows/oauth_tests-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 0aa7e33c7b..91725ccfa8 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -79,6 +79,9 @@ jobs: pip install mwoauth pip install packaging pip install requests + - name: Print public IP of runner + run: | + python -c "import urllib.request; print('Public IP:', urllib.request.urlopen('https://api.ipify.org').read().decode('utf-8'))" - name: Generate family files if: ${{ matrix.family == 'wpbeta' }} run: | From aee9ad747237876620e1a906ed5583e5df42ab9f Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 14 Jul 2025 10:13:53 +0200 Subject: [PATCH 028/279] family: fix language alias for sgs -> bat-smg Bug: T399438 Change-Id: I7ecc2e77a5cc7e1c7019ddd41b433bb42c8cd21c --- pywikibot/family.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pywikibot/family.py b/pywikibot/family.py index 32987e7d4b..f71c6997fe 100644 --- a/pywikibot/family.py +++ b/pywikibot/family.py @@ -962,8 +962,9 @@ class WikimediaFamily(Family): 'jp': 'ja', # Language aliases - 'nb': 'no', # T86924 'gsw': 'als', # T399411 + 'nb': 'no', # T86924 + 'sgs': 'bat-smg', # T399438 # Closed wiki redirection aliases 'mo': 'ro', From 3fb18e8878599c611f1ee34e7564cfe01f3a416a Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 9 Jul 2025 09:45:14 +0200 Subject: [PATCH 029/279] IMPR: process_unlimited: warn if a username is not configured for site Bug: T135228 Change-Id: I8c39f42b6c63bd168af91f411d2ebd6f27228df7 --- scripts/interwiki.py | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/scripts/interwiki.py b/scripts/interwiki.py index 06af442e59..f441e5161d 100755 --- a/scripts/interwiki.py +++ b/scripts/interwiki.py @@ -1512,21 +1512,32 @@ def process_limit_two(self, new, updated) -> None: break def process_unlimited(self, new, updated) -> None: - """Post process unlimited.""" - for (site, page) in new.items(): - # if we have an account for this site - if site.family.name in config.usernames \ - and site.code in config.usernames[site.family.name] \ - and not site.has_data_repository: - # Try to do the changes - try: - if self.replaceLinks(page, new): - # Page was changed - updated.append(site) - except SaveError: - pass - except GiveUpOnPage: - break + """"Post-process pages: replace links and track updated sites.""" + for site, page in new.items(): + if site.has_data_repository: + self.conf.note( + f'{site} has a data repository, skipping {page}' + ) + continue + + # Check if a username is configured for this site + codes = config.usernames.get(site.family.name, []) + if site.code not in codes: + pywikibot.warning( + f'username for {site} is not given in your user-config.py' + ) + continue + + # Try to do the changes + try: + changed = self.replaceLinks(page, new) + except SaveError: + continue + except GiveUpOnPage: + break + + if changed: + updated.append(site) def _fetch_text(self, page: pywikibot.Page) -> str: """Validate page and load it's content for editing. From 4f6ba483db80465d0c5b52facc91070cab291bc7 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 8 Jul 2025 18:18:57 +0200 Subject: [PATCH 030/279] [bugfix] Ignore SectionError in page_empty_check and count it as empty Also move it to Subject as static method Bug: T398983 Change-Id: I0b228b3d3c45a10fccb13909e7018ca9f637e05c --- scripts/interwiki.py | 55 +++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/scripts/interwiki.py b/scripts/interwiki.py index f441e5161d..abbde43f14 100755 --- a/scripts/interwiki.py +++ b/scripts/interwiki.py @@ -384,6 +384,7 @@ NoPageError, NoUsernameError, PageSaveRelatedError, + SectionError, ServerError, SiteDefinitionError, SpamblacklistError, @@ -1129,7 +1130,7 @@ def check_page(self, page, counter) -> None: # must be behind the page.isRedirectPage() part # otherwise a redirect error would be raised - if page_empty_check(page): + if self.page_empty_check(page): self.conf.remove.append(str(page)) self.conf.note(f'{page} is empty. Skipping.') if page == self.origin: @@ -1568,7 +1569,7 @@ def _fetch_text(self, page: pywikibot.Page) -> str: pywikibot.info(f'Not editing {page}: page does not exist') raise SaveError("Page doesn't exist") - if page_empty_check(page): + if self.page_empty_check(page): pywikibot.info(f'Not editing {page}: page is empty') raise SaveError('Page is empty.') @@ -1816,6 +1817,34 @@ def reportBacklinks(new, updatedSites) -> None: pywikibot.warning(f'{page.site.family.name}: {page} links ' f'to incorrect {linkedPage}') + @staticmethod + def page_empty_check(page: pywikibot.Page) -> bool: + """Return True if page should be skipped as it is almost empty. + + Pages in content namespaces are considered empty if they contain + fewer than 50 characters, and other pages are considered empty if + they are not category pages and contain fewer than 4 characters + excluding interlanguage links and categories. + """ + try: + txt = page.text + except SectionError: + # Section doesn't exist — treat page as empty + return True + + # Check if the page is in content namespace + if page.namespace().content: + # Check if the page contains at least 50 characters + return len(txt) < 50 + + if not page.is_categorypage(): + site = page.site + txt = textlib.removeLanguageLinks(txt, site=site) + txt = textlib.removeCategoryLinks(txt, site=site) + return len(txt.strip()) < 4 + + return False + class InterwikiBot: @@ -2128,28 +2157,6 @@ def botMayEdit(page) -> bool: return True -def page_empty_check(page) -> bool: - """Return True if page should be skipped as it is almost empty. - - Pages in content namespaces are considered empty if they contain - less than 50 characters, and other pages are considered empty if - they are not category pages and contain less than 4 characters - excluding interlanguage links and categories. - """ - txt = page.text - # Check if the page is in content namespace - if page.namespace().content: - # Check if the page contains at least 50 characters - return len(txt) < 50 - - if not page.is_categorypage(): - txt = textlib.removeLanguageLinks(txt, site=page.site) - txt = textlib.removeCategoryLinks(txt, site=page.site) - return len(txt) < 4 - - return False - - class InterwikiDumps(OptionHandler): """Handle interwiki dumps.""" From 3f641f9b2a0f173ccdef8cecc1a1e7a599b5a446 Mon Sep 17 00:00:00 2001 From: Xqt Date: Wed, 16 Jul 2025 07:47:36 +0000 Subject: [PATCH 031/279] L10N: Add code alias vro for fiu-vro Bug: T399444 Change-Id: I2f110bc84a085160095c9387224c75ad4d06e4b6 Signed-off-by: Xqt --- pywikibot/family.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pywikibot/family.py b/pywikibot/family.py index f71c6997fe..66c8bc1ee1 100644 --- a/pywikibot/family.py +++ b/pywikibot/family.py @@ -965,6 +965,7 @@ class WikimediaFamily(Family): 'gsw': 'als', # T399411 'nb': 'no', # T86924 'sgs': 'bat-smg', # T399438 + 'vro': 'fiu-vro', # T399444 # Closed wiki redirection aliases 'mo': 'ro', From 61ae7bc6f6d5dc3df16fffe9275205fffe2ad4ee Mon Sep 17 00:00:00 2001 From: Xqt Date: Wed, 16 Jul 2025 08:28:06 +0000 Subject: [PATCH 032/279] L10N: Add code alias rup for roa-rup Bug: T399693 Change-Id: I784f6003d66e16f5c0e510219c239647bdd80be8 Signed-off-by: Xqt --- pywikibot/family.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pywikibot/family.py b/pywikibot/family.py index 66c8bc1ee1..6ac10ac030 100644 --- a/pywikibot/family.py +++ b/pywikibot/family.py @@ -964,6 +964,7 @@ class WikimediaFamily(Family): # Language aliases 'gsw': 'als', # T399411 'nb': 'no', # T86924 + 'rup': 'roa-rup', # T399693 'sgs': 'bat-smg', # T399438 'vro': 'fiu-vro', # T399444 From 8f4572e6ced1b5e5e0685d4939c69dd95a35b225 Mon Sep 17 00:00:00 2001 From: Xqt Date: Wed, 16 Jul 2025 09:13:53 +0000 Subject: [PATCH 033/279] L10N: Add code alias lzh for zh-classical Bug: T399697 Change-Id: I02ff5c4c3428d3188c4a5d51d09134439e64ff32 Signed-off-by: Xqt --- pywikibot/family.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pywikibot/family.py b/pywikibot/family.py index 6ac10ac030..0c4a9794b1 100644 --- a/pywikibot/family.py +++ b/pywikibot/family.py @@ -963,6 +963,7 @@ class WikimediaFamily(Family): # Language aliases 'gsw': 'als', # T399411 + 'lzh': 'zh-classical', # T399697 'nb': 'no', # T86924 'rup': 'roa-rup', # T399693 'sgs': 'bat-smg', # T399438 From b3dfa63955896dd4ebc9c3bb73c1e0ab531cb42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Harald=20S=C3=B8by?= Date: Wed, 16 Jul 2025 12:37:32 +0200 Subject: [PATCH 034/279] Update git submodules * Update scripts/i18n from branch 'master' to e61345fcd4eadc5934764b35ce0fa908853bd2c9 - Add i18n files for tracking_param_remover script Bug: T399698 Change-Id: Ie8b65589b89c406e27b2b3ac837a2526565f955a --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 332854eb19..e61345fcd4 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 332854eb199f62ce99f9c2294fb729b6239086f7 +Subproject commit e61345fcd4eadc5934764b35ce0fa908853bd2c9 From 074e1b9abe40401233444bc23637aab547721a6a Mon Sep 17 00:00:00 2001 From: Xqt Date: Wed, 16 Jul 2025 12:04:12 +0000 Subject: [PATCH 035/279] i18n: update i18n docstring test Bug: T399698 Change-Id: I38f4be1c6c3fb2d96b124a496e576c647e295727 Signed-off-by: Xqt --- pywikibot/i18n.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pywikibot/i18n.py b/pywikibot/i18n.py index e6c7a872a2..ef32722b4c 100644 --- a/pywikibot/i18n.py +++ b/pywikibot/i18n.py @@ -884,7 +884,7 @@ def bundles(stem: bool = False) -> Generator[Path | str, None, None]: >>> from pywikibot import i18n >>> bundles = sorted(i18n.bundles(stem=True)) >>> len(bundles) - 39 + 40 >>> bundles[:4] ['add_text', 'archivebot', 'basic', 'blockpageschecker'] >>> bundles[-5:] From 6050d3406863ca2d4a86fcb593030d172c68eda6 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 16 Jul 2025 21:45:23 +0200 Subject: [PATCH 036/279] Tests: generate family file for wsbeta:en only There are too many unuseful and unsupported redirects to mul.wikisource.org Bug: T399692 Change-Id: I5524bdeadda01f74598fb00f5ea0939882fa41a3 --- .github/workflows/login_tests-ci.yml | 2 +- .github/workflows/pywikibot-ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index 66e06ab767..7f1d29e8f3 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -93,7 +93,7 @@ jobs: python pwb.py generate_family_file http://${{matrix.code}}.wikipedia.beta.wmcloud.org/ wpbeta y fi if [ ${{matrix.site || 0}} == 'wsbeta:en' ]; then - python pwb.py generate_family_file http://en.wikisource.beta.wmcloud.org/ wsbeta y + python pwb.py generate_family_file http://en.wikisource.beta.wmcloud.org/ wsbeta n fi - name: Generate user files run: | diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index e36bcc55af..8489ebd232 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -104,7 +104,7 @@ jobs: python pwb.py generate_family_file http://${{matrix.code}}.wikipedia.beta.wmcloud.org/ wpbeta y fi if [ ${{matrix.site || 0}} == 'wsbeta:en' ]; then - python pwb.py generate_family_file http://en.wikisource.beta.wmcloud.org/ wsbeta y + python pwb.py generate_family_file http://en.wikisource.beta.wmcloud.org/ wsbeta n fi - name: Generate user files run: | From 870ae4aafcc67f503d76f7b25f02004ebdf7f591 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 17 Jul 2025 14:28:05 +0200 Subject: [PATCH 037/279] Update git submodules * Update scripts/i18n from branch 'master' to 470b0b9cfdf660651bb7bbc8b9c872f7689a58df - Localisation updates from https://translatewiki.net. Change-Id: Ic621d72d75e98dada097c0ada06d72ac544e1ca1 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index e61345fcd4..470b0b9cfd 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit e61345fcd4eadc5934764b35ce0fa908853bd2c9 +Subproject commit 470b0b9cfdf660651bb7bbc8b9c872f7689a58df From 6485eff58dd97c4f885efe264c0210463e0e8fb4 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 11 Jul 2025 11:50:26 +0200 Subject: [PATCH 038/279] cleanup throttle: deprecate requestsize and rename methods for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deprecate unused `requestsize` parameter from `Throttle.__call__` - Deprecate `next_multiplicity` property - Rename `getDelay` → `get_delay` for PEP 8 compliance - Rename `setDelays` → `set_delays` accordingly - Simplify `__call__` and `get_delay` logic - Improve docstrings for clarity and consistency Bug: T399266 Change-Id: I69397a247f4adc97201e70b32e8d08989300c668 --- pywikibot/throttle.py | 117 ++++++++++++++++++++++++++++++------------ 1 file changed, 85 insertions(+), 32 deletions(-) diff --git a/pywikibot/throttle.py b/pywikibot/throttle.py index d35960f33c..837a989adc 100644 --- a/pywikibot/throttle.py +++ b/pywikibot/throttle.py @@ -17,7 +17,6 @@ from __future__ import annotations import itertools -import math import threading import time from collections import Counter @@ -28,7 +27,7 @@ import pywikibot from pywikibot import config from pywikibot.backports import Counter as CounterType -from pywikibot.tools import deprecated +from pywikibot.tools import deprecate_positionals, deprecated, deprecated_args FORMAT_LINE = '{module_id} {pid} {time} {site}\n' @@ -89,7 +88,6 @@ def __init__(self, site: pywikibot.site.BaseSite | str, *, self.writedelay = writedelay or config.put_throttle self.last_read = 0.0 self.last_write = 0.0 - self.next_multiplicity = 1.0 self.retry_after = 0 # set by http.request self.delay = 0 @@ -99,6 +97,25 @@ def __init__(self, site: pywikibot.site.BaseSite | str, *, self.checkMultiplicity() self.setDelays() + @property + @deprecated(since='10.3.0') + def next_multiplicity(self) -> float: + """Factor to scale delay time based on upcoming request size. + + .. deprecated:: 10.3.0 + """ + return 1.0 + + @next_multiplicity.setter + @deprecated(since='10.3.0') + def next_multiplicity(self, value: float) -> None: + """Setter for delay scaling factor for the next request. + + .. deprecated:: 10.3.0 + This property has no effect and is retained for backward + compatibility. + """ + @property @deprecated('expiry', since='8.4.0') def dropdelay(self): @@ -209,6 +226,7 @@ def checkMultiplicity(self) -> None: pywikibot.log(f'Found {count} {mysite} processes running,' ' including this one.') + @deprecated('set_delays', since='10.3.0') def setDelays( self, delay=None, @@ -217,7 +235,23 @@ def setDelays( ) -> None: """Set the nominal delays in seconds. + .. deprecated:: 10.3.0 + Use :meth:`set_delays` instead. + """ + self.set_delays(delay=delay, writedelay=writedelay, absolute=absolute) + + def set_delays( + self, *, + delay=None, + writedelay=None, + absolute: bool = False + ) -> None: + """Set the nominal delays in seconds. + Defaults to config values. + + .. versionadded:: 10.3.0 + Renamed from :meth:`setDelays`. """ with self.lock: delay = delay or self.mindelay @@ -231,24 +265,38 @@ def setDelays( # Start the delay count now, not at the next check self.last_read = self.last_write = time.time() - def getDelay(self, write: bool = False): - """Return the actual delay, accounting for multiple processes. + @deprecated('get_delay', since='10.3.0') + def getDelay(self, write: bool = False) -> float: + """Return the current delay, adjusted for active processes. - This value is the maximum wait between reads/writes, not taking - into account of how much time has elapsed since the last access. + .. deprecated:: 10.3.0 + Use :meth:`get_delay` instead. """ - thisdelay = self.writedelay if write else self.delay + return self.get_delay(write=write) + + def get_delay(self, *, write: bool = False) -> float: + """Return the current delay, adjusted for active processes. - # We're checking for multiple processes + Compute the delay for a read or write operation, factoring in + process concurrency. This method does not account for how much + time has already passed since the last access — use + :meth:`waittime` for that. + + .. versionadded:: 10.3.0 + Renamed from :meth:`getDelay`. + + :param write: Whether the operation is a write (uses writedelay). + :return: The delay in seconds before the next operation should + occur. + """ + current_delay = self.writedelay if write else self.delay + + # Refresh process count if the check interval has elapsed if time.time() > self.checktime + self.checkdelay: self.checkMultiplicity() - multiplied_delay = self.mindelay * self.next_multiplicity - if thisdelay < multiplied_delay: - thisdelay = multiplied_delay - elif thisdelay > self.maxdelay: - thisdelay = self.maxdelay - thisdelay *= self.process_multiplicity - return thisdelay + + current_delay = max(self.mindelay, min(current_delay, self.maxdelay)) + return current_delay * self.process_multiplicity def waittime(self, write: bool = False): """Return waiting time in seconds. @@ -257,7 +305,7 @@ def waittime(self, write: bool = False): """ # Take the previous requestsize in account calculating the desired # delay this time - thisdelay = self.getDelay(write=write) + thisdelay = self.get_delay(write=write) now = time.time() ago = now - (self.last_write if write else self.last_read) return max(0.0, thisdelay - ago) @@ -294,31 +342,36 @@ def wait(seconds: int | float) -> None: time.sleep(seconds) - def __call__(self, requestsize: int = 1, write: bool = False) -> None: - """Block the calling program if the throttle time has not expired. + @deprecated_args(requestsize=None) # since: 10.3.0 + @deprecate_positionals(since='10.3.0') + def __call__(self, *, requestsize: int = 1, write: bool = False) -> None: + """Apply throttling based on delay rules and request type. + + This method blocks the calling thread if the minimum delay has + not yet elapsed since the last read or write operation. - Parameter requestsize is the number of Pages to be read/written; - multiply delay time by an appropriate factor. + .. versionchanged:: 10.3.0 + The *write* parameter is now keyword-only. - Because this seizes the throttle lock, it will prevent any other - thread from writing to the same site until the wait expires. + .. deprecated:: 10.3.0 + The *requestsize* parameter has no effect and will be removed + in a future release. + + :param requestsize: Number of pages to be read or written. + Deprecated since 10.3.0. No longer affects throttling. + :param write: Whether the operation involves writing to the site. + Write operations use a separate delay timer and lock. """ lock = self.lock_write if write else self.lock_read with lock: wait = self.waittime(write=write) - # Calculate the multiplicity of the next delay based on how - # big the request is that is being posted now. - # We want to add "one delay" for each factor of two in the - # size of the request. Getting 64 pages at once allows 6 times - # the delay time for the server. - self.next_multiplicity = math.log(1 + requestsize) / math.log(2.0) - self.wait(wait) + now = time.time() if write: - self.last_write = time.time() + self.last_write = now else: - self.last_read = time.time() + self.last_read = now def lag(self, lagtime: float | None = None) -> None: """Seize the throttle lock due to server lag. From 981b125b86928b4931b32aec554d5343605cf357 Mon Sep 17 00:00:00 2001 From: Xqt Date: Fri, 18 Jul 2025 07:24:02 +0000 Subject: [PATCH 039/279] Fix: Update setDelays call after method was renamed Change-Id: Ib8a3ac0a4565ee2dc01e0489c78b7dd8ce7dacd3 Signed-off-by: Xqt --- pywikibot/throttle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pywikibot/throttle.py b/pywikibot/throttle.py index 837a989adc..9d8c7da11d 100644 --- a/pywikibot/throttle.py +++ b/pywikibot/throttle.py @@ -95,7 +95,7 @@ def __init__(self, site: pywikibot.site.BaseSite | str, *, self.modules: CounterType[str] = Counter() self.checkMultiplicity() - self.setDelays() + self.set_delays() @property @deprecated(since='10.3.0') From f3d3f230821b712a38516f469349b7ff13ae30b5 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 12 Jul 2025 16:33:43 +0200 Subject: [PATCH 040/279] Tests: backport Python 3.14 assertion methods With Python 3.14 new assertion methods were introduced - Add a Python314AssertionsMixin class to tests.aspects to implement these methos for older Python releases - Add this mixin class to aspects.TestCase if Python < 3.14 - Use the new methods for tests - update older tests with corresponding assert methods Change-Id: Iaeafa3352d91e0d1dffa7e7d395e39bd4992ac03 --- tests/archivebot_tests.py | 6 +- tests/aspects.py | 165 +++++++++++++++++++++++++++-- tests/basepage.py | 20 ++-- tests/bot_tests.py | 6 +- tests/datasite_tests.py | 10 +- tests/edit_tests.py | 8 +- tests/file_tests.py | 6 +- tests/generate_user_files_tests.py | 6 +- tests/http_tests.py | 3 +- tests/oauth_tests.py | 4 +- tests/page_tests.py | 4 +- tests/pagegenerators_tests.py | 12 +-- tests/proofreadpage_tests.py | 6 +- tests/protectbot_tests.py | 7 +- tests/site_detect_tests.py | 6 +- tests/site_generators_tests.py | 92 ++++++++-------- tests/site_tests.py | 13 ++- tests/textlib_tests.py | 2 +- tests/tools_tests.py | 12 +-- tests/ui_tests.py | 5 +- tests/upload_tests.py | 10 +- tests/wikibase_tests.py | 60 +++++------ tests/xmlreader_tests.py | 4 +- 23 files changed, 304 insertions(+), 163 deletions(-) diff --git a/tests/archivebot_tests.py b/tests/archivebot_tests.py index d580b62075..5ea6734083 100755 --- a/tests/archivebot_tests.py +++ b/tests/archivebot_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for archivebot scripts.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -123,7 +123,7 @@ def test_archivebot(self, code=None) -> None: talk = archivebot.DiscussionPage(page, None) self.assertIsInstance(talk.archives, dict) self.assertIsInstance(talk.archived_threads, int) - self.assertTrue(talk.archiver is None) + self.assertIsNone(talk.archiver) self.assertIsInstance(talk.header, str) self.assertIsInstance(talk.timestripper, TimeStripper) @@ -182,7 +182,7 @@ def test_archivebot(self, code=None) -> None: talk = archivebot.DiscussionPage(page, None) self.assertIsInstance(talk.archives, dict) self.assertIsInstance(talk.archived_threads, int) - self.assertTrue(talk.archiver is None) + self.assertIsNone(talk.archiver) self.assertIsInstance(talk.header, str) self.assertIsInstance(talk.timestripper, TimeStripper) diff --git a/tests/aspects.py b/tests/aspects.py index fa4203f858..c80b2cbace 100644 --- a/tests/aspects.py +++ b/tests/aspects.py @@ -15,6 +15,7 @@ import re import sys import time +import types import unittest import warnings from collections.abc import Sized @@ -37,6 +38,7 @@ from pywikibot.family import WikimediaFamily from pywikibot.site import BaseSite from pywikibot.tools import ( # noqa: F401 (used by eval()) + PYTHON_VERSION, MediaWikiVersion, suppress_warnings, ) @@ -63,6 +65,151 @@ pywikibot.bot.set_interface('buffer') +class Python314AssertionsMixin: + + """Mixin providing assertion methods added in Python 3.14 for unittest. + + This mixin ensures TestCase compatibility on older Python versions. + + The mixin will be removed without deprecation period once Python 3.14 + becomes the minimum requirement for Pywikibot, likely with Pywikibot 16. + + .. versionadded:: 10.3 + """ + + def assertStartsWith(self, s: str, prefix: str, + msg: str | None = None) -> None: + """Fail if the string *s* does not start with *prefix*. + + :param s: The string to check. + :param prefix: The expected prefix. + :param msg: Optional custom failure message. + """ + if s.startswith(prefix): + return + + variant = 'any of ' if isinstance(prefix, tuple) else '' + default_msg = f'{s!r} does not start with {variant}{prefix!r}' + self.fail(self._formatMessage(msg, default_msg)) + + def assertNotStartsWith(self, s: str, prefix: str, + msg: str | None = None) -> None: + """Fail if the string *s* starts with *prefix*. + + :param s: The string to check. + :param prefix: The unwanted prefix. + :param msg: Optional custom failure message. + """ + if not s.startswith(prefix): + return + + default_msg = f'{s!r} starts with {prefix!r}' + self.fail(self._formatMessage(msg, default_msg)) + + def assertEndsWith(self, s: str, suffix: str, + msg: str | None = None) -> None: + """Fail if the string *s* does not end with *suffix*. + + :param s: The string to check. + :param suffix: The expected suffix. + :param msg: Optional custom failure message. + """ + if s.endswith(suffix): + return + + variant = 'any of ' if isinstance(suffix, tuple) else '' + default_msg = f'{s!r} does not end with {variant}{suffix!r}' + self.fail(self._formatMessage(msg, default_msg)) + + def assertNotEndsWith(self, s: str, suffix: str, + msg: str | None = None) -> None: + """Fail if the string *s* ends with *suffix*. + + :param s: The string to check. + :param suffix: The unwanted suffix. + :param msg: Optional custom failure message. + """ + if not s.endswith(suffix): + return + + default_msg = f'{s!r} ends with {suffix!r}' + self.fail(self._formatMessage(msg, default_msg)) + + def assertHasAttr(self, obj: object, name: str, + msg: str | None = None) -> None: + """Fail if the object *obj* does not have an attribute *name*. + + :param obj: The object to check. + :param name: The expected attribute name. + :param msg: Optional custom failure message. + """ + if hasattr(obj, name): + return + + if isinstance(obj, types.ModuleType): + obj_name = f'module {obj.__name__!r}' + elif isinstance(obj, type): + obj_name = f'type object {obj.__name__!r}' + else: + obj_name = f'{type(obj).__name__!r}' + + default_msg = f'{obj_name} does not have attribute {name!r}' + self.fail(self._formatMessage(msg, default_msg)) + + def assertNotHasAttr(self, obj: object, name: str, + msg: str | None = None) -> None: + """Fail if the object *obj* has an attribute *name*. + + :param obj: The object to check. + :param name: The unwanted attribute name. + :param msg: Optional custom failure message. + """ + if not hasattr(obj, name): + return + + if isinstance(obj, types.ModuleType): + obj_name = f'module {obj.__name__!r}' + elif isinstance(obj, type): + obj_name = f'type object {obj.__name__!r}' + else: + obj_name = f'{type(obj).__name__!r}' + + default_msg = f'{obj_name} has attribute {name!r}' + self.fail(self._formatMessage(msg, default_msg)) + + def assertIsSubclass(self, cls: type, superclass: type | tuple[type, ...], + msg: str | None = None) -> None: + """Fail if *cls* is not a subclass of *superclass*. + + :param cls: The class to test. + :param superclass: The expected superclass or tuple of superclasses. + :param msg: Optional custom failure message. + """ + if issubclass(cls, superclass): + return + + default_msg = f'{cls!r} is not a subclass of {superclass!r}' + self.fail(self._formatMessage(msg, default_msg)) + + def assertNotIsSubclass( + self, + cls: type, + superclass: type | tuple[type, ...], + msg: str | None = None + ) -> None: + """Fail if *cls* is a subclass of *superclass*. + + :param cls: The class to test. + :param superclass: The superclass or tuple of superclasses to reject. + :param msg: Optional custom failure message. + """ + if not issubclass(cls, superclass): + return + + default_msg = f'{cls!r} is a subclass of {superclass!r}' + self.fail(self._formatMessage(msg, default_msg)) + + class TestTimerMixin(unittest.TestCase): """Time each test and report excessive durations.""" @@ -86,22 +233,27 @@ def tearDown(self) -> None: sys.stdout.flush() -class TestCaseBase(TestTimerMixin): +# Add Python314AssertionsMixin on Python < 3.14 +if PYTHON_VERSION < (3, 14): + bases = (TestTimerMixin, Python314AssertionsMixin) +else: + bases = (TestTimerMixin, ) + + +class TestCaseBase(*bases): """Base class for all tests.""" def assertIsEmpty(self, seq, msg=None) -> None: """Check that the sequence is empty.""" - self.assertIsInstance( - seq, Sized, SIZED_ERROR) + self.assertIsInstance(seq, Sized, SIZED_ERROR) if seq: msg = self._formatMessage(msg, f'{safe_repr(seq)} is not empty') self.fail(msg) def assertIsNotEmpty(self, seq, msg=None) -> None: """Check that the sequence is not empty.""" - self.assertIsInstance( - seq, Sized, SIZED_ERROR) + self.assertIsInstance(seq, Sized, SIZED_ERROR) if not seq: msg = self._formatMessage(msg, f'{safe_repr(seq)} is empty') self.fail(msg) @@ -109,8 +261,7 @@ def assertIsNotEmpty(self, seq, msg=None) -> None: def assertLength(self, seq, other, msg=None) -> None: """Verify that a sequence seq has the length of other.""" # the other parameter may be given as a sequence too - self.assertIsInstance( - seq, Sized, SIZED_ERROR) + self.assertIsInstance(seq, Sized, SIZED_ERROR) first_len = len(seq) try: second_len = len(other) diff --git a/tests/basepage.py b/tests/basepage.py index 3903067509..651569b06d 100644 --- a/tests/basepage.py +++ b/tests/basepage.py @@ -1,6 +1,6 @@ """BasePage tests subclasses.""" # -# (C) Pywikibot team, 2015-2022 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -44,9 +44,9 @@ def _test_page_text(self, get_text=True) -> None: """Test site.loadrevisions() with .text.""" page = self._page - self.assertFalse(hasattr(page, '_revid')) - self.assertFalse(hasattr(page, '_text')) - self.assertTrue(hasattr(page, '_revisions')) + self.assertNotHasAttr(page, '_revid') + self.assertNotHasAttr(page, '_text') + self.assertHasAttr(page, '_revisions') self.assertFalse(page._revisions) # verify that initializing the page content @@ -57,8 +57,8 @@ def _test_page_text(self, get_text=True) -> None: page._revisions = {} self.site.loadrevisions(page, total=1) - self.assertTrue(hasattr(page, '_revid')) - self.assertTrue(hasattr(page, '_revisions')) + self.assertHasAttr(page, '_revid') + self.assertHasAttr(page, '_revisions') self.assertLength(page._revisions, 1) self.assertIn(page._revid, page._revisions) @@ -66,7 +66,7 @@ def _test_page_text(self, get_text=True) -> None: self.assertEqual(page.text, page._text) del page.text - self.assertFalse(hasattr(page, '_text')) + self.assertNotHasAttr(page, '_text') self.assertIsNone(page._revisions[page._revid].text) self.assertIsNone(page._latest_cached_revision()) @@ -78,7 +78,7 @@ def _test_page_text(self, get_text=True) -> None: self.assertEqual(page._text, custom_text) self.assertEqual(page.text, page._text) del page.text - self.assertFalse(hasattr(page, '_text')) + self.assertNotHasAttr(page, '_text') # Verify that calling .text doesn't call loadrevisions again loadrevisions = self.site.loadrevisions @@ -91,14 +91,14 @@ def _test_page_text(self, get_text=True) -> None: page.text loaded_text = '' self.assertIsNotNone(loaded_text) - self.assertFalse(hasattr(page, '_text')) + self.assertNotHasAttr(page, '_text') page.text = custom_text if get_text: self.assertEqual(page.get(), loaded_text) self.assertEqual(page._text, custom_text) self.assertEqual(page.text, page._text) del page.text - self.assertFalse(hasattr(page, '_text')) + self.assertNotHasAttr(page, '_text') if get_text: self.assertEqual(page.text, loaded_text) finally: diff --git a/tests/bot_tests.py b/tests/bot_tests.py index 86d62afaaf..d5df8b44a3 100755 --- a/tests/bot_tests.py +++ b/tests/bot_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Bot tests.""" # -# (C) Pywikibot team, 2015-2024 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -56,8 +56,8 @@ def _treat(self, pages, post_treat=None): def treat(page) -> None: self.assertEqual(page, next(self._page_iter)) if self._treat_site is None: - self.assertFalse(hasattr(self.bot, 'site')) - self.assertFalse(hasattr(self.bot, '_site')) + self.assertNotHasAttr(self.bot, 'site') + self.assertNotHasAttr(self.bot, '_site') elif not isinstance(self.bot, pywikibot.bot.MultipleSitesBot): self.assertIsNotNone(self.bot._site) self.assertEqual(self.bot.site, self.bot._site) diff --git a/tests/datasite_tests.py b/tests/datasite_tests.py index dc0132bdbf..77272ed71b 100755 --- a/tests/datasite_tests.py +++ b/tests/datasite_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for the site module.""" # -# (C) Pywikibot team, 2014-2022 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -27,7 +27,7 @@ def test_item(self) -> None: seen = [] for item in datasite.preload_entities(items): self.assertIsInstance(item, pywikibot.ItemPage) - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertNotIn(item, seen) seen.append(item) self.assertLength(seen, 5) @@ -42,7 +42,7 @@ def test_item_as_page(self) -> None: seen = [] for item in datasite.preload_entities(pages): self.assertIsInstance(item, pywikibot.ItemPage) - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertNotIn(item, seen) seen.append(item) self.assertLength(seen, 5) @@ -53,7 +53,7 @@ def test_property(self) -> None: page = pywikibot.Page(datasite, 'P6') property_page = next(datasite.preload_entities([page])) self.assertIsInstance(property_page, pywikibot.PropertyPage) - self.assertTrue(hasattr(property_page, '_content')) + self.assertHasAttr(property_page, '_content') class TestDataSiteClientPreloading(DefaultWikidataClientTestCase): @@ -67,7 +67,7 @@ def test_non_item(self) -> None: item = next(datasite.preload_entities([mainpage])) self.assertIsInstance(item, pywikibot.ItemPage) - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertEqual(item.id, 'Q5296') diff --git a/tests/edit_tests.py b/tests/edit_tests.py index 852fd715c5..7fcd1d1f0c 100755 --- a/tests/edit_tests.py +++ b/tests/edit_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for editing pages.""" # -# (C) Pywikibot team, 2015-2024 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -63,11 +63,11 @@ def test_appendtext(self) -> None: """Test writing to a page without preloading the .text.""" ts = str(time.time()) p = pywikibot.Page(self.site, 'User:John Vandenberg/appendtext test') - self.assertFalse(hasattr(p, '_text')) + self.assertNotHasAttr(p, '_text') p.site.editpage(p, appendtext=ts) - self.assertFalse(hasattr(p, '_text')) + self.assertNotHasAttr(p, '_text') p = pywikibot.Page(self.site, 'User:John Vandenberg/appendtext test') - self.assertTrue(p.text.endswith(ts)) + self.assertEndsWith(p.text, ts) self.assertNotEqual(p.text, ts) diff --git a/tests/file_tests.py b/tests/file_tests.py index ebb6f7d1ab..4d6e611e91 100755 --- a/tests/file_tests.py +++ b/tests/file_tests.py @@ -358,8 +358,8 @@ def test_data_item(self) -> None: page = pywikibot.FilePage(self.site, 'File:Albert Einstein.jpg') item = page.data_item() self.assertIsInstance(item, pywikibot.MediaInfo) - self.assertTrue(page._item is item) - self.assertTrue(item.file is page) + self.assertIs(page._item, item) + self.assertIs(item.file, page) self.assertEqual('-1', item.id) item.get() self.assertEqual('M14634781', item.id) @@ -368,7 +368,7 @@ def test_data_item(self) -> None: item.labels, pywikibot.page._collections.LanguageDict) self.assertIsInstance( item.statements, pywikibot.page._collections.ClaimCollection) - self.assertTrue(item.claims is item.statements) + self.assertIs(item.claims, item.statements) all_claims = list(chain.from_iterable(item.statements.values())) self.assertEqual({claim.on_item for claim in all_claims}, {item}) diff --git a/tests/generate_user_files_tests.py b/tests/generate_user_files_tests.py index 858931913f..28b2575099 100755 --- a/tests/generate_user_files_tests.py +++ b/tests/generate_user_files_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Test generate_user_files script.""" # -# (C) Pywikibot team, 2018-2023 +# (C) Pywikibot team, 2018-2025 # # Distributed under the terms of the MIT license. # @@ -29,8 +29,8 @@ def test_ask_for_dir_change(self) -> None: def test_base_names(self) -> None: """Test basename constants.""" - self.assertTrue(guf.USER_BASENAME.endswith('.py')) - self.assertTrue(guf.PASS_BASENAME.endswith('.py')) + self.assertEndsWith(guf.USER_BASENAME, '.py') + self.assertEndsWith(guf.PASS_BASENAME, '.py') def test_config_test(self) -> None: """Test config text strings.""" diff --git a/tests/http_tests.py b/tests/http_tests.py index 0ee2e0c25e..a9217424f3 100755 --- a/tests/http_tests.py +++ b/tests/http_tests.py @@ -230,8 +230,7 @@ def tearDown(self) -> None: def test_default_user_agent(self) -> None: """Config defined format string test.""" - self.assertTrue(http.user_agent().startswith( - pywikibot.calledModuleName())) + self.assertStartsWith(http.user_agent(), pywikibot.calledModuleName()) self.assertIn('Pywikibot/' + pywikibot.__version__, http.user_agent()) self.assertNotIn(' ', http.user_agent()) self.assertNotIn('()', http.user_agent()) diff --git a/tests/oauth_tests.py b/tests/oauth_tests.py index cecf17fec5..3f95cdea05 100755 --- a/tests/oauth_tests.py +++ b/tests/oauth_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Test OAuth functionality.""" # -# (C) Pywikibot team, 2015-2024 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -87,7 +87,7 @@ def test_edit(self) -> None: p = pywikibot.Page(self.site, title) t = p.text if revision_id == p.latest_revision_id: - self.assertTrue(p.text.endswith(ts)) + self.assertEndsWith(p.text, ts) else: self.assertIn(ts, t) diff --git a/tests/page_tests.py b/tests/page_tests.py index 6d9b30dbdf..5c00543ce6 100755 --- a/tests/page_tests.py +++ b/tests/page_tests.py @@ -570,14 +570,14 @@ def test_redirect(self) -> None: else: self.skipTest(f'No redirect pages on site {site!r}') # This page is already initialised - self.assertTrue(hasattr(page, '_isredir')) + self.assertHasAttr(page, '_isredir') # call api.update_page without prop=info del page._isredir page.isDisambig() self.assertTrue(page.isRedirectPage()) page_copy = pywikibot.Page(site, page.title()) - self.assertFalse(hasattr(page_copy, '_isredir')) + self.assertNotHasAttr(page_copy, '_isredir') page_copy.isDisambig() self.assertTrue(page_copy.isRedirectPage()) diff --git a/tests/pagegenerators_tests.py b/tests/pagegenerators_tests.py index fe59e6a252..b6397970a0 100755 --- a/tests/pagegenerators_tests.py +++ b/tests/pagegenerators_tests.py @@ -79,7 +79,7 @@ def setUp(self) -> None: def assertFunction(self, obj) -> None: """Assert function test.""" - self.assertTrue(hasattr(pagegenerators, obj)) + self.assertHasAttr(pagegenerators, obj) self.assertTrue(callable(getattr(pagegenerators, obj))) def test_module_import(self) -> None: @@ -596,7 +596,7 @@ def test_basic(self) -> None: self.assertIsInstance(page.exists(), bool) self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') self.assertLength(links, count) def test_low_step(self) -> None: @@ -611,7 +611,7 @@ def test_low_step(self) -> None: self.assertIsInstance(page.exists(), bool) self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') self.assertLength(links, count) def test_order(self) -> None: @@ -625,7 +625,7 @@ def test_order(self) -> None: self.assertIsInstance(page.exists(), bool) self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') self.assertEqual(page, links[count]) self.assertLength(links, count + 1) @@ -1077,7 +1077,7 @@ def test_prefixing_default(self) -> None: self.assertLessEqual(len(pages), 10) for page in pages: self.assertIsInstance(page, pywikibot.Page) - self.assertTrue(page.title().lower().startswith('a')) + self.assertStartsWith(page.title().lower(), 'a') def test_prefixing_ns(self) -> None: """Test prefixindex generator with namespace filter.""" @@ -1666,7 +1666,7 @@ def test_RC_pagegenerator_result(self) -> None: testentry = entries[0] self.assertEqual(testentry.site, site) - self.assertTrue(hasattr(testentry, '_rcinfo')) + self.assertHasAttr(testentry, '_rcinfo') rcinfo = testentry._rcinfo self.assertEqual(rcinfo['server_name'], site.hostname()) diff --git a/tests/proofreadpage_tests.py b/tests/proofreadpage_tests.py index 2d50c9503b..4d908d928f 100755 --- a/tests/proofreadpage_tests.py +++ b/tests/proofreadpage_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for the proofreadpage module.""" # -# (C) Pywikibot team, 2015-2024 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -343,7 +343,7 @@ def test_valid_parsing(self) -> None: def test_div_in_footer(self) -> None: """Test ProofreadPage page parsing functions.""" page = ProofreadPage(self.site, self.div_in_footer['title']) - self.assertTrue(page.footer.endswith('')) + self.assertEndsWith(page.footer, '') def test_decompose_recompose_text(self) -> None: """Test ProofreadPage page decomposing/composing text.""" @@ -543,7 +543,7 @@ def test_index(self) -> None: # Test deleter del page.index - self.assertFalse(hasattr(page, '_index')) + self.assertNotHasAttr(page, '_index') # Test setter with wrong type. with self.assertRaises(TypeError): page.index = 'invalid index' diff --git a/tests/protectbot_tests.py b/tests/protectbot_tests.py index faf404eee3..7279aa505b 100755 --- a/tests/protectbot_tests.py +++ b/tests/protectbot_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for scripts/protect.py.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -54,10 +54,11 @@ def test_summary(self) -> None: self.maxDiff = None comment = rev[0].comment - self.assertTrue(comment.startswith( + self.assertStartsWith( + comment, 'Protected "[[User:Sn1per/ProtectTest2]]": Bot: ' 'Protecting all pages from category Pywikibot Protect Test' - )) + ) # the order may change, see T367259 for ptype in ('Edit', 'Move'): self.assertIn(f'[{ptype}=Allow only administrators] (indefinite)', diff --git a/tests/site_detect_tests.py b/tests/site_detect_tests.py index 7cbd7f6c85..8d3ddc8047 100755 --- a/tests/site_detect_tests.py +++ b/tests/site_detect_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Test for site detection.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -196,7 +196,7 @@ def fetch(self, url, *args, **kwargs): @PatchingTestCase.patched(pywikibot, 'input') def input(self, question, *args, **kwargs): """Patched version of pywikibot.input.""" - self.assertTrue(question.endswith('username?')) + self.assertEndsWith(question, 'username?') return self.USERNAME @PatchingTestCase.patched(pywikibot, 'Site') @@ -227,7 +227,7 @@ def test_T235768_failure(self) -> None: """ site = MWSite(self._weburl) self.assertIsInstance(site, MWSite) - self.assertTrue(hasattr(site, 'lang')) + self.assertHasAttr(site, 'lang') self.assertEqual(site.lang, self.LANG) diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index a36749a94f..1deb37b9c4 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -275,7 +275,7 @@ def test_allpages(self) -> None: self.assertGreaterEqual(page.title(), 'Py') for page in mysite.allpages(prefix='Pre', total=5): if self.validate_page(page): - self.assertTrue(page.title().startswith('Pre')) + self.assertStartsWith(page.title(), 'Pre') for page in mysite.allpages(namespace=1, total=5): self.validate_page(page, namespace=1) for page in mysite.allpages(filterredir=True, total=5): @@ -359,10 +359,7 @@ def test_all_links(self) -> None: for page in mysite.alllinks(prefix='Fix', total=5): self.assertIsInstance(page, pywikibot.Page) self.assertEqual(page.namespace(), 0) - self.assertTrue( - page.title().startswith('Fix'), - msg=f"{page.title()} does not start with 'Fix'" - ) + self.assertStartsWith(page.title(), 'Fix') # increase timeout due to T359427/T359425 # ~ 47s are required on wikidata @@ -379,7 +376,7 @@ def test_all_links(self) -> None: fromids=True, total=5): self.assertIsInstance(page, pywikibot.Page) self.assertGreaterEqual(page.title(with_ns=False), 'From') - self.assertTrue(hasattr(page, '_fromid')) + self.assertHasAttr(page, '_fromid') with self.subTest( msg='Test that Error is raised with unique and fromids'): @@ -400,8 +397,7 @@ def test_all_categories(self) -> None: self.assertGreaterEqual(cat.title(with_ns=False), 'Abc') for cat in mysite.allcategories(total=5, prefix='Def'): self.assertIsInstance(cat, pywikibot.Category) - self.assertTrue(cat.title(with_ns=False).startswith('Def')) - # Bug T17985 - reverse and start combined; fixed in v 1.14 + self.assertStartsWith(cat.title(with_ns=False), 'Def') for cat in mysite.allcategories(total=5, start='Hij', reverse=True): self.assertIsInstance(cat, pywikibot.Category) self.assertLessEqual(cat.title(with_ns=False), 'Hij') @@ -426,7 +422,7 @@ def test_all_images(self) -> None: for impage in mysite.allimages(prefix='Ch', total=5): self.assertIsInstance(impage, pywikibot.FilePage) self.assertTrue(impage.exists()) - self.assertTrue(impage.title(with_ns=False).startswith('Ch')) + self.assertStartsWith(impage.title(with_ns=False), 'Ch') for impage in mysite.allimages(minsize=100, total=5): self.assertIsInstance(impage, pywikibot.FilePage) self.assertTrue(impage.exists()) @@ -768,7 +764,7 @@ def test_allusers_with_prefix(self) -> None: for user in mysite.allusers(prefix='C', total=5): self.assertIsInstance(user, dict) self.assertIn('name', user) - self.assertTrue(user['name'].startswith('C')) + self.assertStartsWith(user['name'], 'C') self.assertIn('editcount', user) self.assertIn('registration', user) @@ -778,7 +774,7 @@ def test_allusers_with_group(self) -> None: for user in mysite.allusers(prefix='D', group='bot', total=5): self.assertIsInstance(user, dict) self.assertIn('name', user) - self.assertTrue(user['name'].startswith('D')) + self.assertStartsWith(user['name'], 'D') self.assertIn('editcount', user) self.assertIn('registration', user) self.assertIn('groups', user) @@ -1109,7 +1105,7 @@ def test_watched_pages_uncached(self) -> None: """Test the site.watched_pages() method uncached.""" gen = self.site.watched_pages(total=5, force=True) self.assertIsInstance(gen.request, api.Request) - self.assertFalse(issubclass(gen.request_class, api.CachedRequest)) + self.assertNotIsSubclass(gen.request_class, api.CachedRequest) for page in gen: self.assertIsInstance(page, pywikibot.Page) @@ -1188,7 +1184,7 @@ def test_namespaces(self) -> None: namespaces=14, total=5): self.assertIsInstance(contrib, dict) self.assertIn('title', contrib) - self.assertTrue(contrib['title'].startswith(mysite.namespace(14))) + self.assertStartsWith(contrib['title'], mysite.namespace(14)) for contrib in mysite.usercontribs(user=mysite.user(), namespaces=[10, 11], total=5): @@ -1224,7 +1220,7 @@ def test_user_prefix(self) -> None: self.assertIsInstance(contrib, dict) for key in ('user', 'title', 'ns', 'pageid', 'revid'): self.assertIn(key, contrib) - self.assertTrue(contrib['user'].startswith('John')) + self.assertStartsWith(contrib['user'], 'John') def test_user_prefix_range(self) -> None: """Test the site.usercontribs() method.""" @@ -1327,7 +1323,7 @@ def test_namespaces(self) -> None: for data in mysite.alldeletedrevisions(namespaces=14, total=5): self.assertIsInstance(data, dict) self.assertIn('title', data) - self.assertTrue(data['title'].startswith(mysite.namespace(14))) + self.assertStartsWith(data['title'], mysite.namespace(14)) for data in mysite.alldeletedrevisions(user=mysite.user(), namespaces=[10, 11], @@ -1470,7 +1466,7 @@ def test_prefix(self) -> None: title = data['title'] if data['ns'] > 0: *_, title = title.partition(':') - self.assertTrue(title.startswith('John')) + self.assertStartsWith(title, 'John') self.assertIsInstance(data['revisions'], list) for drev in data['revisions']: self.assertIsInstance(drev, dict) @@ -1577,7 +1573,7 @@ def test_users(self) -> None: self.assertIn('missing', user) elif self.site.family.name == 'wikipedia': self.assertNotIn('missing', user) - self.assertEqual(cnt, len(all_users), 'Some test usernames not found') + self.assertLength(all_users, cnt, 'Some test usernames not found') class SiteRandomTestCase(DefaultSiteTestCase): @@ -1693,7 +1689,7 @@ def test_loadrevisions_basic(self) -> None: # Load revisions without content self.mysite.loadrevisions(self.mainpage, total=15) self.mysite.loadrevisions(self.mainpage) - self.assertFalse(hasattr(self.mainpage, '_text')) + self.assertNotHasAttr(self.mainpage, '_text') self.assertLength(self.mainpage._revisions, 15) self.assertIn(self.mainpage._revid, self.mainpage._revisions) self.assertIsNone(self.mainpage._revisions[self.mainpage._revid].text) @@ -1703,7 +1699,7 @@ def test_loadrevisions_basic(self) -> None: def test_loadrevisions_content(self) -> None: """Test the site.loadrevisions() method with content=True.""" self.mysite.loadrevisions(self.mainpage, content=True, total=5) - self.assertFalse(hasattr(self.mainpage, '_text')) + self.assertNotHasAttr(self.mainpage, '_text') self.assertIn(self.mainpage._revid, self.mainpage._revisions) self.assertIsNotNone( self.mainpage._revisions[self.mainpage._revid].text) @@ -1885,7 +1881,7 @@ def test_filearchive_prefix(self) -> None: gen = self.site.filearchive(prefix='py') self.assertIn('faprefix=py', str(gen.request)) for item in gen: - self.assertTrue(item['name'].startswith('Py')) + self.assertStartsWith(item['name'], 'Py') def test_filearchive_prop(self) -> None: """Test properties.""" @@ -1947,9 +1943,9 @@ def test_load_from_pageids_iterable_of_str(self) -> None: self.assertIsInstance(page, pywikibot.Page) self.assertIsInstance(page.exists(), bool) self.assertTrue(page.exists()) - self.assertTrue(hasattr(page, '_pageid')) + self.assertHasAttr(page, '_pageid') self.assertIn(page, self.links) - self.assertEqual(count, len(self.links)) + self.assertLength(self.links, count) def test_load_from_pageids_iterable_of_int(self) -> None: """Test basic loading with pageids.""" @@ -1960,9 +1956,9 @@ def test_load_from_pageids_iterable_of_int(self) -> None: self.assertIsInstance(page, pywikibot.Page) self.assertIsInstance(page.exists(), bool) self.assertTrue(page.exists()) - self.assertTrue(hasattr(page, '_pageid')) + self.assertHasAttr(page, '_pageid') self.assertIn(page, self.links) - self.assertEqual(count, len(self.links)) + self.assertLength(self.links, count) def test_load_from_pageids_iterable_in_order(self) -> None: """Test loading with pageids is ordered.""" @@ -1973,7 +1969,7 @@ def test_load_from_pageids_iterable_in_order(self) -> None: self.assertIsInstance(page, pywikibot.Page) self.assertIsInstance(page.exists(), bool) self.assertTrue(page.exists()) - self.assertTrue(hasattr(page, '_pageid')) + self.assertHasAttr(page, '_pageid') self.assertEqual(page, link) def test_load_from_pageids_iterable_with_duplicate(self) -> None: @@ -1986,9 +1982,9 @@ def test_load_from_pageids_iterable_with_duplicate(self) -> None: self.assertIsInstance(page, pywikibot.Page) self.assertIsInstance(page.exists(), bool) self.assertTrue(page.exists()) - self.assertTrue(hasattr(page, '_pageid')) + self.assertHasAttr(page, '_pageid') self.assertIn(page, self.links) - self.assertEqual(count, len(self.links)) + self.assertLength(self.links, count) def test_load_from_pageids_comma_separated(self) -> None: """Test loading from comma-separated pageids.""" @@ -1999,9 +1995,9 @@ def test_load_from_pageids_comma_separated(self) -> None: self.assertIsInstance(page, pywikibot.Page) self.assertIsInstance(page.exists(), bool) self.assertTrue(page.exists()) - self.assertTrue(hasattr(page, '_pageid')) + self.assertHasAttr(page, '_pageid') self.assertIn(page, self.links) - self.assertEqual(count, len(self.links)) + self.assertLength(self.links, count) def test_load_from_pageids_pipe_separated(self) -> None: """Test loading from comma-separated pageids.""" @@ -2012,9 +2008,9 @@ def test_load_from_pageids_pipe_separated(self) -> None: self.assertIsInstance(page, pywikibot.Page) self.assertIsInstance(page.exists(), bool) self.assertTrue(page.exists()) - self.assertTrue(hasattr(page, '_pageid')) + self.assertHasAttr(page, '_pageid') self.assertIn(page, self.links) - self.assertEqual(count, len(self.links)) + self.assertLength(self.links, count) class TestPagePreloading(DefaultSiteTestCase): @@ -2049,11 +2045,11 @@ def test_pageids(self) -> None: self.assertIsInstance(page, pywikibot.Page) self.assertIsInstance(page.exists(), bool) if page.exists(): - self.assertTrue(hasattr(page, '_revid')) + self.assertHasAttr(page, '_revid') self.assertLength(page._revisions, 1) self.assertIn(page._revid, page._revisions) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') if count >= 5: break @@ -2076,7 +2072,7 @@ def test_titles(self) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') if count >= 5: break @@ -2091,7 +2087,7 @@ def test_preload_continuation(self) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') if count >= 5: break @@ -2117,7 +2113,7 @@ def test_preload_high_groupsize(self) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') self.assertEqual(count, link_count) def test_preload_low_groupsize(self) -> None: @@ -2142,7 +2138,7 @@ def test_preload_low_groupsize(self) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') self.assertEqual(count, link_count) def test_preload_unexpected_titles_using_pageids(self) -> None: @@ -2168,7 +2164,7 @@ def test_preload_unexpected_titles_using_pageids(self) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') if count >= 5: break @@ -2195,7 +2191,7 @@ def test_preload_unexpected_titles_using_titles(self) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') if count >= 5: break @@ -2234,8 +2230,8 @@ def test_preload_langlinks_normal(self) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) - self.assertTrue(hasattr(page, '_langlinks')) + self.assertNotHasAttr(page, '_pageprops') + self.assertHasAttr(page, '_langlinks') if count >= 5: break @@ -2257,7 +2253,7 @@ def test_preload_langlinks_count(self, output_mock) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) + self.assertNotHasAttr(page, '_pageprops') if pages: self.assertRegex( output_mock.call_args[0][0], r'Retrieving \d pages from ') @@ -2275,8 +2271,8 @@ def test_preload_templates(self) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) - self.assertTrue(hasattr(page, '_templates')) + self.assertNotHasAttr(page, '_pageprops') + self.assertHasAttr(page, '_templates') if count >= 5: break @@ -2294,9 +2290,9 @@ def test_preload_templates_and_langlinks(self) -> None: if page.exists(): self.assertLength(page._revisions, 1) self.assertIsNotNone(page._revisions[page._revid].text) - self.assertFalse(hasattr(page, '_pageprops')) - self.assertTrue(hasattr(page, '_templates')) - self.assertTrue(hasattr(page, '_langlinks')) + self.assertNotHasAttr(page, '_pageprops') + self.assertHasAttr(page, '_templates') + self.assertHasAttr(page, '_langlinks') if count >= 5: break @@ -2307,7 +2303,7 @@ def test_preload_categories(self) -> None: gen = mysite.preloadpages(cats, categories=True) for count, page in enumerate(gen): with self.subTest(page=page.title()): - self.assertTrue(hasattr(page, '_categories')) + self.assertHasAttr(page, '_categories') # content=True will bypass cache self.assertEqual(page._categories, set(page.categories(content=True))) diff --git a/tests/site_tests.py b/tests/site_tests.py index d62f266ebb..b061b18467 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -54,8 +54,7 @@ def test_repr(self) -> None: code = self.site.family.obsolete.get(self.code) or self.code expect = f"Site('{code}', '{self.family}')" reprs = repr(self.site) - self.assertTrue(reprs.endswith(expect), - f'\n{reprs} does not end with {expect}') + self.assertEndsWith(reprs, expect) def test_constructors(self) -> None: """Test cases for site constructors.""" @@ -715,7 +714,7 @@ def test_revdel_file(self) -> None: site.loadimageinfo(fp1, history=True) for v in fp1._file_revisions.values(): if v['timestamp'] == ts1: - self.assertTrue(hasattr(v, 'userhidden')) + self.assertHasAttr(v, 'userhidden') # Multiple revisions site.deleterevs('oldimage', '20210314184415|20210314184430', @@ -726,7 +725,7 @@ def test_revdel_file(self) -> None: site.loadimageinfo(fp2, history=True) for v in fp2._file_revisions.values(): if v['timestamp'] in (ts1, ts2): - self.assertTrue(hasattr(v, 'commenthidden')) + self.assertHasAttr(v, 'commenthidden') # Concurrently show and hide site.deleterevs('oldimage', ['20210314184415', '20210314184430'], @@ -738,9 +737,9 @@ def test_revdel_file(self) -> None: site.loadimageinfo(fp3, history=True) for v in fp3._file_revisions.values(): if v['timestamp'] in (ts1, ts2): - self.assertFalse(hasattr(v, 'commenthidden')) - self.assertFalse(hasattr(v, 'userhidden')) - self.assertFalse(hasattr(v, 'filehidden')) + self.assertNotHasAttr(v, 'commenthidden') + self.assertNotHasAttr(v, 'userhidden') + self.assertNotHasAttr(v, 'filehidden') # Cleanup site.deleterevs('oldimage', [20210314184415, 20210314184430], diff --git a/tests/textlib_tests.py b/tests/textlib_tests.py index 6c61a3bbf5..f8601096d0 100755 --- a/tests/textlib_tests.py +++ b/tests/textlib_tests.py @@ -622,7 +622,7 @@ def test_nested_template_regex_match(self) -> None: self.assertIsNone(m['params']) self.assertIsNone(m[2]) self.assertIsNotNone(m['unhandled_depth']) - self.assertTrue(m[0].endswith('foo {{bar}}')) + self.assertEndsWith(m[0], 'foo {{bar}}') class TestDisabledParts(DefaultDrySiteTestCase): diff --git a/tests/tools_tests.py b/tests/tools_tests.py index b3f578a53a..91d2604929 100755 --- a/tests/tools_tests.py +++ b/tests/tools_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Test tools package alone which don't fit into other tests.""" # -# (C) Pywikibot team, 2015-2024 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. from __future__ import annotations @@ -1024,11 +1024,11 @@ def test_cached(self) -> None: """Test for cached decorator.""" self.assertEqual(self.foo.foo(), 'foo') # check computed value self.assertEqual(self.foo.read, 1) - self.assertTrue(hasattr(self.foo, '_foo')) + self.assertHasAttr(self.foo, '_foo') self.assertEqual(self.foo.foo(), 'foo') # check cached value self.assertEqual(self.foo.read, 1) # bar() was called only once del self.foo._foo - self.assertFalse(hasattr(self.foo, '_foo')) + self.assertNotHasAttr(self.foo, '_foo') self.assertEqual(self.foo.foo(), 'foo') # check computed value self.assertEqual(self.foo.__doc__, 'Test class to verify cached decorator.') @@ -1038,7 +1038,7 @@ def test_cached_property(self) -> None: """Test for cached property decorator.""" self.assertEqual(self.foo.bar, 'bar') self.assertEqual(self.foo.read, 1) - self.assertTrue(hasattr(self.foo, '_bar')) + self.assertHasAttr(self.foo, '_bar') self.assertEqual(self.foo.bar, 'bar') self.assertEqual(self.foo.read, 1) @@ -1054,7 +1054,7 @@ def test_cached_with_force(self) -> None: """Test for cached decorator with force enabled.""" self.assertEqual(self.foo.quux(), 'quux') self.assertEqual(self.foo.read, 1) - self.assertTrue(hasattr(self.foo, '_quux')) + self.assertHasAttr(self.foo, '_quux') self.assertEqual(self.foo.quux(force=True), 'quux') self.assertEqual(self.foo.read, 2) @@ -1063,7 +1063,7 @@ def test_cached_with_argse(self) -> None: self.assertEqual(self.foo.method_with_args(force=False), 'method_with_args') self.assertEqual(self.foo.read, 1) - self.assertTrue(hasattr(self.foo, '_method_with_args')) + self.assertHasAttr(self.foo, '_method_with_args') with self.assertRaises(TypeError): self.foo.method_with_args(True) with self.assertRaises(TypeError): diff --git a/tests/ui_tests.py b/tests/ui_tests.py index 8c29d34db2..dc3daa367b 100755 --- a/tests/ui_tests.py +++ b/tests/ui_tests.py @@ -185,10 +185,7 @@ def test_exception_tb(self) -> None: self.assertEqual(stderrlines[1], 'Traceback (most recent call last):') self.assertEqual(stderrlines[3], " raise ExceptionTestError('Testing Exception')") - - end_str = ': Testing Exception' - self.assertTrue(stderrlines[-1].endswith(end_str), - f'\n{stderrlines[-1]!r} does not end with {end_str!r}') + self.assertEndsWith(stderrlines[-1], ': Testing Exception') class TestTerminalInput(UITestCase): diff --git a/tests/upload_tests.py b/tests/upload_tests.py index 03c85a476a..96e8043ca7 100755 --- a/tests/upload_tests.py +++ b/tests/upload_tests.py @@ -4,7 +4,7 @@ These tests write to the wiki. """ # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -64,13 +64,13 @@ def warn_callback(warnings) -> None: # First upload the warning with warnings enabled page = pywikibot.FilePage(self.site, 'MP_sounds-pwb.png') - self.assertFalse(hasattr(self, '_file_key')) + self.assertNotHasAttr(self, '_file_key') self.site.upload(page, source_filename=self.sounds_png, comment='pywikibot test', chunk_size=chunk_size, ignore_warnings=warn_callback) # Check that the warning happened and it's cached - self.assertTrue(hasattr(self, '_file_key')) + self.assertHasAttr(self, '_file_key') self.assertIs(self._offset, True) self.assertRegex(self._file_key, r'[0-9a-z]+.[0-9a-z]+.\d+.png') self._verify_stash() @@ -100,9 +100,7 @@ def _test_continue_filekey(self, chunk_size) -> None: # Check if it's still cached with self.assertAPIError('siiinvalidsessiondata') as cm: self.site.stash_info(self._file_key) - self.assertTrue(cm.exception.info.startswith('File not found'), - f'info ({cm.exception.info}) did not start with ' - '"File not found"') + self.assertStartsWith(cm.exception.info, 'File not found') @unittest.expectedFailure # T367314 def test_continue_filekey_once(self) -> None: diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py index 1a28147ad5..b07adb15c1 100755 --- a/tests/wikibase_tests.py +++ b/tests/wikibase_tests.py @@ -91,7 +91,7 @@ def testWikibase(self) -> None: item2 = ItemPage(repo, 'q5296') self.assertEqual(item2.getID(), 'Q5296') item2.get() - self.assertTrue(item2.labels['en'].lower().endswith('main page')) + self.assertEndsWith(item2.labels['en'].lower(), 'main page') prop = PropertyPage(repo, 'Property:P21') self.assertEqual(prop.type, 'wikibase-item') self.assertEqual(prop.namespace(), 120) @@ -248,14 +248,14 @@ def test_item_normal(self) -> None: self.assertEqual(item._link._title, 'Q60') self.assertEqual(item._defined_by(), {'ids': 'Q60'}) self.assertEqual(item.id, 'Q60') - self.assertFalse(hasattr(item, '_title')) - self.assertFalse(hasattr(item, '_site')) + self.assertNotHasAttr(item, '_title') + self.assertNotHasAttr(item, '_site') self.assertEqual(item.title(), 'Q60') self.assertEqual(item.getID(), 'Q60') self.assertEqual(item.getID(numeric=True), 60) - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, '_content') item.get() - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') def test_item_lazy_initialization(self) -> None: """Test that Wikibase items are properly initialized lazily.""" @@ -279,11 +279,11 @@ def test_load_item_set_id(self) -> None: item = ItemPage(wikidata, '-1') self.assertEqual(item._link._title, '-1') item.id = 'Q60' - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, '_content') self.assertEqual(item.getID(), 'Q60') - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, '_content') item.get() - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertIn('en', item.labels) # label could change self.assertIn(item.labels['en'], ['New York', 'New York City']) @@ -363,24 +363,24 @@ def test_item_missing(self) -> None: item = ItemPage(wikidata, 'Q7') self.assertEqual(item._link._title, 'Q7') self.assertEqual(item.title(), 'Q7') - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, '_content') self.assertEqual(item.id, 'Q7') self.assertEqual(item.getID(), 'Q7') numeric_id = item.getID(numeric=True) self.assertIsInstance(numeric_id, int) self.assertEqual(numeric_id, 7) - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, '_content') regex = r"^Page .+ doesn't exist\.$" with self.assertRaisesRegex(NoPageError, regex): item.get() - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertEqual(item.id, 'Q7') self.assertEqual(item.getID(), 'Q7') self.assertEqual(item._link._title, 'Q7') self.assertEqual(item.title(), 'Q7') with self.assertRaisesRegex(NoPageError, regex): item.get() - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertEqual(item._link._title, 'Q7') self.assertEqual(item.getID(), 'Q7') self.assertEqual(item.title(), 'Q7') @@ -401,10 +401,10 @@ def test_fromPage_noprops(self) -> None: page = self.nyc item = ItemPage.fromPage(page) self.assertEqual(item._link._title, '-1') - self.assertTrue(hasattr(item, 'id')) - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, 'id') + self.assertHasAttr(item, '_content') self.assertEqual(item.title(), 'Q60') - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertEqual(item.id, 'Q60') self.assertEqual(item.getID(), 'Q60') self.assertEqual(item.getID(numeric=True), 60) @@ -416,10 +416,10 @@ def test_fromPage_noprops_with_section(self) -> None: page = pywikibot.Page(self.nyc.site, self.nyc.title() + '#foo') item = ItemPage.fromPage(page) self.assertEqual(item._link._title, '-1') - self.assertTrue(hasattr(item, 'id')) - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, 'id') + self.assertHasAttr(item, '_content') self.assertEqual(item.title(), 'Q60') - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertEqual(item.id, 'Q60') self.assertEqual(item.getID(), 'Q60') self.assertEqual(item.getID(numeric=True), 60) @@ -434,18 +434,18 @@ def test_fromPage_props(self) -> None: item = ItemPage.fromPage(page) self.assertEqual(item._link._title, 'Q60') self.assertEqual(item.id, 'Q60') - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, '_content') self.assertEqual(item.title(), 'Q60') - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, '_content') self.assertEqual(item.id, 'Q60') self.assertEqual(item.getID(), 'Q60') self.assertEqual(item.getID(numeric=True), 60) - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, '_content') item.get() - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertTrue(item.exists()) item2 = ItemPage.fromPage(page) - self.assertTrue(item is item2) + self.assertIs(item, item2) def test_fromPage_lazy(self) -> None: """Test item from page with lazy_load.""" @@ -454,10 +454,10 @@ def test_fromPage_lazy(self) -> None: self.assertEqual(item._defined_by(), {'sites': 'enwiki', 'titles': 'New York City'}) self.assertEqual(item._link._title, '-1') - self.assertFalse(hasattr(item, 'id')) - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, 'id') + self.assertNotHasAttr(item, '_content') self.assertEqual(item.title(), 'Q60') - self.assertTrue(hasattr(item, '_content')) + self.assertHasAttr(item, '_content') self.assertEqual(item.id, 'Q60') self.assertEqual(item.getID(), 'Q60') self.assertEqual(item.getID(numeric=True), 60) @@ -482,10 +482,10 @@ def _test_fromPage_noitem(self, link) -> None: item = ItemPage.fromPage(page, lazy_load=True) - self.assertFalse(hasattr(item, 'id')) - self.assertTrue(hasattr(item, '_title')) - self.assertTrue(hasattr(item, '_site')) - self.assertFalse(hasattr(item, '_content')) + self.assertNotHasAttr(item, 'id') + self.assertHasAttr(item, '_title') + self.assertHasAttr(item, '_site') + self.assertNotHasAttr(item, '_content') self.assertEqual(item._link._title, '-1') # the method 'exists' does not raise an exception diff --git a/tests/xmlreader_tests.py b/tests/xmlreader_tests.py index e9fa31914a..c53afdc968 100755 --- a/tests/xmlreader_tests.py +++ b/tests/xmlreader_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for xmlreader module.""" # -# (C) Pywikibot team, 2009-2024 +# (C) Pywikibot team, 2009-2025 # # Distributed under the terms of the MIT license. # @@ -37,7 +37,7 @@ def test_XmlDumpAllRevs(self) -> None: self.assertEqual('24278', pages[0].id) self.assertEqual('185185', pages[0].revisionid) self.assertEqual('188924', pages[3].revisionid) - self.assertTrue(pages[0].text.startswith('Pears are [[tree]]s of')) + self.assertStartsWith(pages[0].text, 'Pears are [[tree]]s of') self.assertEqual('Quercusrobur', pages[1].username) self.assertEqual('Pear', pages[0].title) From 3d6d402b198e10701e1c73089567f78d0a27057e Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 19 Jul 2025 15:02:34 +0200 Subject: [PATCH 041/279] doc: Update ROADMAP.rst and CHANGELOG.rst Change-Id: I8ccd50a16ae2ab2a63405f1ce61053d508e56abc --- ROADMAP.rst | 126 +++++++++++++++++++++++++++--------------- scripts/CHANGELOG.rst | 13 ++++- 2 files changed, 90 insertions(+), 49 deletions(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index 26bbc03422..fef751015b 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,84 +1,118 @@ Current Release Changes ======================= +* Refactor the :class:`throttle.Trottle` class (:phab:`T289318`) +* L10N-Updates: add language aliases for ``gsw``, ``sgs``, ``vro``, ``rup`` and ``lzh`` + to :class:`family.WikimediaFamily` family class + (:phab:`T399411`, :phab:`T399438`, :phab:`T399444`, :phab:`T399693`, :phab:`T399697` ) +* Refactor HTML removal logic in :func:`textlib.removeHTMLParts` using :class:`textlib.GetDataHTML` + parser; *removetags* parameter was introduced to remove specified tag blocks (:phab:`T399378`) +* Refactor :class:`echo.Notification` and fix :meth:`mark_as_read()` + method (:phab:`T398770`) +* Update beta domains in family files from beta.wmflabs.org to beta.wmcloud.org (:phab:`T289318`) * ``textlib.to_latin_digits()`` was renamed to :func:`textlib.to_ascii_digits` (:phab:`T398146#10958283`), - ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` was renamed to ``NON_ASCII_DIGITS``. -* Add -cookies option to :mod:`login` script to login with cookies files only -* Create a Site using :func:`pywikibot.Site` constructor with a given url even if the url ends with - a slash (:phab:`T396592`) + ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` was renamed to ``NON_ASCII_DIGITS`` +* Add -cookies option to the :mod:`login` script to log in with cookies + files only +* Create a Site using the :func:`pywikibot.Site` constructor with a given url even if the URL, even + if it ends with a slash (:phab:`T396592`) * Remove hard-coded error messages from :meth:`login.LoginManager.login` and use API response instead -* Add additional informations to :meth:`Site.login()` error message (:phab:`T395670`) +* Add additional information to :meth:`Site.login()` + error message (:phab:`T395670`) * i18n updates -Current Deprecations -==================== +Deprecations +============ -* 10.3.0: ``textlib.to_latin_digits()`` will be removed in favour of :func:`textlib.to_ascii_digits`, - ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` will be removed in favour of ``NON_ASCII_DIGITS``. +Pending removal in Pywikibot 13 +------------------------------- + +* 10.3.0: :meth:`throttle.Trottle.getDelay` and :meth:`throttle.Trottle.setDelays` were renamed; the + old methods will be removed (:phab:`T289318`) +* 10.3.0: :attr:`throttle.Trottle.next_multiplicity` attribute is unused and will be removed + (:phab:`T289318`) +* 10.3.0: *requestsize* parameter of :class:`throttle.Trottle` call is deprecated and will be + dropped (:phab:`T289318`) +* 10.3.0: :func:`textlib.to_latin_digits` will be removed in favour of + :func:`textlib.to_ascii_digits`, ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` + will be removed in favour of ``NON_ASCII_DIGITS`` (:phab:`T398146#10958283`) * 10.2.0: :mod:`tools.threading.RLock` is deprecated and moved to :mod:`backports` module. The :meth:`backports.RLock.count` method is also deprecated. For Python 3.14+ use ``RLock`` from Python library ``threading`` instead. (:phab:`T395182`) * 10.1.0: *revid* and *date* parameters of :meth:`Page.authorship() ` were dropped -* 10.0.0: *last_id* of :class:`comms.eventstreams.EventStreams` was renamed to *last_event_id* (:phab:`T309380`) +* 10.0.0: *last_id* of :class:`comms.eventstreams.EventStreams` was renamed to *last_event_id* + (:phab:`T309380`) * 10.0.0: 'millenia' argument for *precision* parameter of :class:`pywikibot.WbTime` is deprecated; - 'millennium' must be used instead. + 'millennium' must be used instead * 10.0.0: *includeredirects* parameter of :func:`pagegenerators.AllpagesPageGenerator` and :func:`pagegenerators.PrefixingPageGenerator` is deprecated and should be replaced by *filterredir* + + +Pending removal in Pywikibot 12 +------------------------------- + * 9.6.0: :meth:`BaseSite.languages()` will be removed in favour of :attr:`BaseSite.codes` * 9.5.0: :meth:`DataSite.getPropertyType()` will be removed in favour of :meth:`DataSite.get_property_type()` * 9.3.0: :meth:`page.BasePage.userName` and :meth:`page.BasePage.isIpEdit` are deprecated in favour of ``user`` or ``anon`` attributes of :attr:`page.BasePage.latest_revision` property -* 9.2.0: Imports of :mod:`logging` functions from :mod:`bot` module is deprecated and will be desupported +* 9.2.0: Imports of :mod:`logging` functions from the :mod:`bot` module are deprecated and will be desupported * 9.2.0: *total* argument in ``-logevents`` pagegenerators option is deprecated; use ``-limit`` instead (:phab:`T128981`) * 9.0.0: The *content* parameter of :meth:`proofreadpage.IndexPage.page_gen` is deprecated and will be ignored (:phab:`T358635`) -* 9.0.0: ``userinterfaces.transliteration.transliterator`` was renamed to :class:`Transliterator - ` -* 9.0.0: ``next`` parameter of :meth:`userinterfaces.transliteration.transliterator.transliterate` was +* 9.0.0: ``next`` parameter of :meth:`userinterfaces.transliteration.Transliterator.transliterate` was renamed to ``succ`` -* 9.0.0: ``type`` parameter of :meth:`site.APISite.protectedpages() +* 9.0.0: ``userinterfaces.transliteration.transliterator`` object was renamed to :class:`Transliterator + ` +* 9.0.0: The ``type`` parameter of :meth:`site.APISite.protectedpages() ` was renamed to ``protect_type`` -* 9.0.0: ``all`` parameter of :meth:`site.APISite.namespace()` - was renamed to ``all_ns`` +* 9.0.0: The ``all`` parameter of :meth:`site.APISite.namespace() + ` was renamed to ``all_ns`` * 9.0.0: ``filter`` parameter of :func:`date.dh` was renamed to ``filter_func`` * 9.0.0: ``dict`` parameter of :class:`data.api.OptionSet` was renamed to ``data`` -* 9.0.0: ``pywikibot.version.get_toolforge_hostname()`` is deprecated without replacement +* 9.0.0: :func:`pywikibot.version.get_toolforge_hostname` is deprecated with no replacement * 9.0.0: ``allrevisions`` parameter of :class:`xmlreader.XmpDump` is deprecated, use ``revisions`` instead (:phab:`T340804`) * 9.0.0: ``iteritems`` method of :class:`data.api.Request` will be removed in favour of ``items`` -* 9.0.0: ``SequenceOutputter.output()`` is deprecated in favour of :attr:`tools.formatter.SequenceOutputter.out` - property +* 9.0.0: ``SequenceOutputter.output()`` is deprecated in favour of the + :attr:`tools.formatter.SequenceOutputter.out` property Pending removal in Pywikibot 11 ------------------------------- -* 8.4.0: *modules_only_mode* parameter of :class:`data.api.ParamInfo`, its *paraminfo_keys* class attribute - and its preloaded_modules property will be removed -* 8.4.0: *dropdelay* and *releasepid* attributes of :class:`throttle.Throttle` will be removed - in favour of *expiry* class attribute -* 8.2.0: :func:`tools.itertools.itergroup` will be removed in favour of :func:`backports.batched` -* 8.2.0: *normalize* parameter of :meth:`WbTime.toTimestr` and :meth:`WbTime.toWikibase` will be removed -* 8.1.0: Dependency of :exc:`exceptions.NoSiteLinkError` from :exc:`exceptions.NoPageError` will be removed -* 8.1.0: ``exceptions.Server414Error`` is deprecated in favour of :exc:`exceptions.Client414Error` -* 8.0.0: :meth:`Timestamp.clone()` method is deprecated - in favour of ``Timestamp.replace()`` method. -* 8.0.0: :meth:`family.Family.maximum_GET_length` method is deprecated in favour of - :ref:`config.maximum_GET_length` (:phab:`T325957`) -* 8.0.0: ``addOnly`` parameter of :func:`textlib.replaceLanguageLinks` and - :func:`textlib.replaceCategoryLinks` are deprecated in favour of ``add_only`` -* 8.0.0: :class:`textlib.TimeStripper` regex attributes ``ptimeR``, ``ptimeznR``, ``pyearR``, ``pmonthR``, - ``pdayR`` are deprecated in favour of ``patterns`` attribute which is a - :class:`textlib.TimeStripperPatterns`. -* 8.0.0: :class:`textlib.TimeStripper` ``groups`` attribute is deprecated in favour of ``textlib.TIMEGROUPS`` -* 8.0.0: :meth:`LoginManager.get_login_token` was - replaced by ``login.ClientLoginManager.site.tokens['login']`` -* 8.0.0: ``data.api.LoginManager()`` is deprecated in favour of :class:`login.ClientLoginManager` -* 8.0.0: :meth:`APISite.messages()` method is deprecated in - favour of :attr:`userinfo['messages']` -* 8.0.0: :meth:`Page.editTime()` method is deprecated and should be replaced by - :attr:`Page.latest_revision.timestamp` +* 8.4.0: The *modules_only_mode* parameter in the :class:`data.api.ParamInfo` class, its + *paraminfo_keys* class attribute, and its ``preloaded_modules`` property will be removed +* 8.4.0: The *dropdelay* and *releasepid* attributes of the :class:`throttle.Throttle` class will be + removed in favour of the *expiry* class attribute +* 8.2.0: The :func:`tools.itertools.itergroup` function will be removed in favour of the + :func:`backports.batched` function +* 8.2.0: The *normalize* parameter in the :meth:`WbTime.toTimestr` and :meth:`WbTime.toWikibase` + methods will be removed +* 8.1.0: The inheritance of the :exc:`exceptions.NoSiteLinkError` exception from + :exc:`exceptions.NoPageError` will be removed +* 8.1.0: The ``exceptions.Server414Error`` exception is deprecated in favour of the + :exc:`exceptions.Client414Error` exception +* 8.0.0: The :meth:`Timestamp.clone()` method is deprecated in + favour of the ``Timestamp.replace()`` method +* 8.0.0: The :meth:`family.Family.maximum_GET_length` method is deprecated in favour of the + :ref:`config.maximum_GET_length` configuration option (:phab:`T325957`) +* 8.0.0: The ``addOnly`` parameter in the :func:`textlib.replaceLanguageLinks` and + :func:`textlib.replaceCategoryLinks` functions is deprecated in favour of ``add_only`` +* 8.0.0: The regex attributes ``ptimeR``, ``ptimeznR``, ``pyearR``, ``pmonthR``, and ``pdayR`` of + the :class:`textlib.TimeStripper` class are deprecated in favour of the ``patterns`` attribute, + which is a :class:`textlib.TimeStripperPatterns` object +* 8.0.0: The ``groups`` attribute of the :class:`textlib.TimeStripper` class is deprecated in favour + of the :data:`textlib.TIMEGROUPS` constant +* 8.0.0: The :meth:`LoginManager.get_login_token` method + has been replaced by ``login.ClientLoginManager.site.tokens['login']`` +* 8.0.0: The ``data.api.LoginManager()`` constructor is deprecated in favour of the + :class:`login.ClientLoginManager` class +* 8.0.0: The :meth:`APISite.messages()` method is + deprecated in favour of the :attr:`userinfo['messages']` + attribute +* 8.0.0: The :meth:`Page.editTime()` method is deprecated and should be + replaced by the :attr:`Page.latest_revision.timestamp` attribute diff --git a/scripts/CHANGELOG.rst b/scripts/CHANGELOG.rst index 62f50ea4d9..4242abafcc 100644 --- a/scripts/CHANGELOG.rst +++ b/scripts/CHANGELOG.rst @@ -9,13 +9,20 @@ Scripts Changelog archivebot ^^^^^^^^^^ -* Use wikidata items for archive header templates (:phab:`T396399`) +* Use Wikidata items for archive header templates (:phab:`T396399`) + +interwiki +^^^^^^^^^ + +* Ignore :exc:`exceptions.SectionError` in :meth:`interwiki.Subject.page_empty_check` and treat it + as an empty page (:phab:`T398983`) +* Show a warning if no username is configured for a site (:phab:`T135228`) redirect ^^^^^^^^ -* Try one more move to fix redirect targets (:phab:`T396473`) -* Don't fix broken redirects if namespace of source and target are different (:phab:`T396456`) +* Attempt an additional move to fix redirect targets (:phab:`T396473`) +* Do not fix broken redirects if the source and target namespaces differ (:phab:`T396456`) 10.2.0 From 371d753f0d03276c484ed1f627df359a105a94ee Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 19 Jul 2025 15:08:20 +0200 Subject: [PATCH 042/279] Tests: Update pre-commit hooks Change-Id: Id35eacf0404e27dbe059fbbed34a8331fcb69adb --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 02695fd842..26a983d983 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.3 + rev: v0.12.4 hooks: - id: ruff-check alias: ruff @@ -113,7 +113,7 @@ repos: - flake8-tuple>=0.4.1 - pep8-naming>=0.15.1 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.16.1 + rev: v1.17.0 hooks: - id: mypy args: From fa20a2d1b49619606bb8e92d7893d3a3934289fb Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 19 Jul 2025 17:09:24 +0200 Subject: [PATCH 043/279] Doc: Fix typo and clarify docstring in aspects.require_version() Change-Id: Ib2d9eeaf90535cae8306e0383eb45896abdb137d --- tests/aspects.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/aspects.py b/tests/aspects.py index c80b2cbace..41f6e6ecd0 100644 --- a/tests/aspects.py +++ b/tests/aspects.py @@ -443,16 +443,16 @@ def test_requirement(obj): def require_version(version_needed: str, /, reason: str = ''): - """Require minimum MediaWiki version to be queried. + """Skip test unless a minimum MediaWiki version is available. - The version needed for the test; must be given with a preleading rich - comparisons operator like ``<1.31wmf4`` or ``>=1.43``. If the - comparison does not match the test will be skipped. + The required version must include a comparison operator (e.g. + :code:`<1.31wmf4` or :code:`>=1.43`). If the site's version does not + satisfy the condition, the test is skipped. - This decorator can only be used for TestCase having a single site. - It cannot be used for DrySite tests. In addition version comparison - for other than the current site e.g. for the related data or image - repositoy of the current site is ot possible. + This decorator can only be used for :class:`TestCase` having a + single site. It cannot be used for DrySite tests. Version checks are + only supported for the current site — not for related sites like + data or image repositories. .. versionadded:: 8.0 @@ -486,6 +486,8 @@ def wrapper(self, *args, **kwargs): ) try: + # Split version string into operator and version + # (e.g. '>=1.39' → '', '>=', '1.39') site_vers, op, version = re.split('([<>]=?)', version_needed) except ValueError: raise ValueError(f'There is no valid operator given with ' From 3fcb7ee0327face98ef1ccf5432289dd2d2183ca Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 21 Jul 2025 09:09:52 +0200 Subject: [PATCH 044/279] Update git submodules * Update scripts/i18n from branch 'master' to 6a468b739c1eaff0679ebaa02026ef3710224ad7 - i18n: fix/expand metadata for tracking_param_remover files Bug: T399698 Change-Id: I2eb544897b483c7fb666895459a4894bdc51b18c --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 470b0b9cfd..6a468b739c 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 470b0b9cfdf660651bb7bbc8b9c872f7689a58df +Subproject commit 6a468b739c1eaff0679ebaa02026ef3710224ad7 From 0b542acc4e10e9a849d49caa7dd0a0d6aa25ede0 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 20 Jul 2025 10:52:47 +0200 Subject: [PATCH 045/279] doc: Refactor formatting of option lists in script docstrings Enhance the pywikibot_script_docstring_fixups() hook to format script docstrings more reliably: - Use newlines list to collect the lines - Detect and wrap colon-containing options with :kbd:`...` - Preserve and indent associated descriptions as definition list entries - Always add blank lines after such entries to satisfy docutils This avoids layout issues and reduces spurious warnings during doc builds. Also - update tomlib; Python 3.11 is required for sphinx 8.2 - update some scripts documentation - add tomllib to isort standard library list Bug: T400000 Change-Id: I191427571e40b64c81351fb73c1dddfb496a29cc --- docs/conf.py | 84 ++++++++++++++++++++-------------- docs/requirements.txt | 1 - pyproject.toml | 1 + scripts/create_isbn_edition.py | 2 +- scripts/djvutext.py | 50 ++++++++++---------- scripts/listpages.py | 62 +++++++++++++------------ 6 files changed, 108 insertions(+), 92 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 1da84b8b74..e3b086d1ff 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,16 +24,12 @@ import os import re import sys +import tomllib import warnings +from itertools import pairwise from pathlib import Path -try: - import tomllib -except ImportError: - import tomli as tomllib - - # Deprecated classes will generate warnings as Sphinx processes them. # Ignoring them. @@ -529,55 +525,75 @@ def pywikibot_docstring_fixups(app, what, name, obj, options, lines) -> None: def pywikibot_script_docstring_fixups(app, what, name, obj, options, lines) -> None: - """Pywikibot specific conversions.""" + """Pywikibot-specific docstring conversions for scripts.""" from scripts.cosmetic_changes import warning if what != 'module' or 'scripts.' not in name: return - length = 0 - desc = '' - for index, line in enumerate(lines): - # highlight the first line - if index == 0: # highlight the first line - lines[0] = f"**{line.strip('.')}**" + if not lines: + return + + nextline = None + # highlight the first line + newlines = [f"**{lines[0].strip('.')}**"] + + for previous, line in pairwise(lines): # add link for pagegenerators options - elif line == '¶ms;': - lines[index] = ('This script supports use of ' - ':py:mod:`pagegenerators` arguments.') + if line == '¶ms;': + newlines.append( + 'This script supports use of :mod:`pagegenerators` arguments.') + continue # add link for fixes - elif name == 'scripts.replace' and line == '&fixes-help;': - lines[index] = (' The available fixes are listed ' - 'in :py:mod:`pywikibot.fixes`.') + if name == 'scripts.replace' and line == '&fixes-help;': + newlines.append(' The available fixes are ' + 'listed in :mod:`pywikibot.fixes`.') + continue # replace cosmetic changes warning - elif name == 'scripts.cosmetic_changes' and line == '&warning;': - lines[index] = warning + if name == 'scripts.cosmetic_changes' and line == '&warning;': + newlines.append(warning) + continue # adjust options: if the option contains a colon, convert it to a # definition list and mark the option with a :kbd: role. Also convert # option types enclosed in square brackets to italic style. if line.startswith('-'): # extract term and wrap it with :kbd: role - match = re.fullmatch(r'(-\w.+?[^ ])( {2,})(.+)', line) + match = re.fullmatch(r'(-\w\S+)(?:( {2,})(.+))?', line) if match: opt, sp, desc = match.groups() - desc = re.sub(r'\[(float|int|str)\]', r'*(\1)*', desc) - if ':' in opt or ' ' in opt and ', ' not in opt: + sp = sp or '' + desc = desc or '' + # make [type] italic + types = '(?:float|int|str)' + desc = re.sub(rf'\[({types}(?:\|{types})*)\]', r'*(\1)*', desc) + show_as_kbd = ':' in opt or (' ' in opt and ', ' not in opt) + if show_as_kbd: + # extract term and wrap it with :kbd: role + if previous: + # add an empty line if previous is not empty + newlines.append('') length = len(opt + sp) - lines[index] = f':kbd:`{opt}`' + newlines.append(f':kbd:`{opt}`') + # add the description to a new line later + if desc: + nextline = length, desc else: - lines[index] = f'{opt}{sp}{desc}' - - elif length and (not line or line.startswith(' ' * length)): - # Add descriptions to the next line - lines[index] = ' ' * length + f'{desc} {line.strip()}' - length = 0 - elif line: - # Reset length - length = 0 + newlines.append(f'{opt}{sp}{desc}') + continue + + if nextline: + spaces = len(line) - len(line.lstrip()) or nextline[0] + newlines.append(' ' * spaces + nextline[1]) + nextline = None + + newlines.append(line) + + # Overwrite original lines in-place for autodoc + lines[:] = newlines def setup(app) -> None: diff --git a/docs/requirements.txt b/docs/requirements.txt index 1a29bd5730..3f42299963 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,5 +6,4 @@ rstcheck >=6.2.4 sphinxext-opengraph >= 0.9.1 sphinx-copybutton >= 0.5.2 sphinx-tabs >= 3.4.7 -tomli >= 2.2.1; python_version < '3.11' furo >= 2024.8.6 diff --git a/pyproject.toml b/pyproject.toml index 4520211573..9ac05830ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,7 @@ include_trailing_comma = true lines_after_imports = 2 multi_line_output = 3 use_parentheses = true +extra_standard_library = ["tomllib"] [tool.mypy] diff --git a/scripts/create_isbn_edition.py b/scripts/create_isbn_edition.py index 98bd02d424..684d4f4238 100755 --- a/scripts/create_isbn_edition.py +++ b/scripts/create_isbn_edition.py @@ -673,7 +673,7 @@ def get_language_preferences() -> list[str]: always appended. .. seealso:: - - :wiki:`List_of_ISO_639-1_codes + - :wiki:`List_of_ISO_639-1_codes` :Return: List of ISO 639-1 language codes with strings delimited by ':'. diff --git a/scripts/djvutext.py b/scripts/djvutext.py index ac9f6404b8..845520ea3c 100755 --- a/scripts/djvutext.py +++ b/scripts/djvutext.py @@ -1,43 +1,41 @@ #!/usr/bin/env python3 -"""This bot uploads text from djvu files onto pages in the "Page" namespace. +"""This bot uploads text from DjVu files onto pages in the "Page" namespace. -.. note:: It is intended to be used for Wikisource. +.. note:: This script is intended to be used for Wikisource. -The following parameters are supported: +The following command-line parameters are supported: --index: name of the index page (without the Index: prefix) +-index: Name of the index page (without the "Index:" prefix). --djvu: path to the djvu file, it shall be: +-djvu: Path to the DjVu file. It can be one of the following: - .. hlist:: + * A path to a file + * A directory containing a DjVu file with the same name as + the index page (optional; defaults to current directory ".") - * path to a file name - * dir where a djvu file name as index is located optional, - by default is current dir '.' +-pages:-,...-,- + Page range(s) to upload (optional). Default: :samp:`start=1`, + :samp:`end={DjVu file number of images}`. Page ranges can be + specified as:: --pages:-,...-,- Page range to - upload; optional, :samp:`start=1`, - :samp:`end={djvu file number of images}`. Page ranges can be - specified as:: + A-B -> pages A through B + A- -> pages A through the end + A -> only page A + -B -> pages 1 through B - A-B -> pages A until B - A- -> pages A until number of images - A -> just page A - -B -> pages 1 until B +This script is a subclass of :class:`ConfigParserBot`. +The following options can be set in a settings file (default: +``scripts.ini``): -This script is a :class:`ConfigParserBot `. The -following options can be set within a settings file which is scripts.ini -by default: +-summary: [str] Custom edit summary. Use quotes if the summary + contains spaces. --summary: [str] Custom edit summary. Use quotes if edit summary - contains spaces. +-force Overwrite existing text. Optional. Default: False. --force Overwrites existing text optional, default False. - --always Do not bother asking to confirm any of the changes. +-always Do not prompt for confirmation before making changes. """ # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # diff --git a/scripts/listpages.py b/scripts/listpages.py index 5226ae6864..c03c2092be 100755 --- a/scripts/listpages.py +++ b/scripts/listpages.py @@ -6,64 +6,66 @@ These parameters are supported to specify which pages titles to print: --format Defines the output format. +-format [int|str] Defines the output format. Can be a custom string according to python string.format() - notation or can be selected by a number from following list - (1 is default format): + notation or can be selected by a number from the following + list (1 is default format): - 1 - '{num:4d} {page.title}' - --> 10 PageTitle + ``1 - '{num:4d} {page.title}'`` + → 10 PageTitle - 2 - '{num:4d} [[{page.title}]]' - --> 10 [[PageTitle]] + ``2 - '{num:4d} [[{page.title}]]'`` + → 10 [[PageTitle]] - 3 - '{page.title}' - --> PageTitle + ``3 - '{page.title}'`` + → PageTitle - 4 - '[[{page.title}]]' - --> [[PageTitle]] + ``4 - '[[{page.title}]]'`` + → [[PageTitle]] - 5 - '{num:4d} <>{page.loc_title:<40}<>' - --> 10 localised_Namespace:PageTitle (colorised in lightred) + ``5 - '{num:4d} <>{page.loc_title:<40}<>'`` + → 10 localised_Namespace:PageTitle (colorised in lightred) - 6 - '{num:4d} {page.loc_title:<40} {page.can_title:<40}' - --> 10 localised_Namespace:PageTitle - canonical_Namespace:PageTitle + ``6 - '{num:4d} {page.loc_title:<40} {page.can_title:<40}'`` + → 10 localised_Namespace:PageTitle + canonical_Namespace:PageTitle - 7 - '{num:4d} {page.loc_title:<40} {page.trs_title:<40}' - --> 10 localised_Namespace:PageTitle - outputlang_Namespace:PageTitle - (*) requires "outputlang:lang" set. + ``7 - '{num:4d} {page.loc_title:<40} {page.trs_title:<40}'`` + → 10 localised_Namespace:PageTitle + outputlang_Namespace:PageTitle - num is the sequential number of the listed page. + .. important:: Requires ``outputlang:lang`` set, see + below. - An empty format is equal to ``-notitle`` and just shows the - total amount of pages. + ``num`` is the sequential number of the listed page. + + .. hint:: An empty format is equal to ``-notitle`` and just + shows the total number of pages. -outputlang - Language for translation of namespaces. + [str] Language for translation of namespaces. -notitle Page title is not printed. -get Page content is printed. --tofile Save Page titles to a single file. File name can be set - with -tofile:filename or -tofile:dir_name/filename. +-tofile [str] Save Page titles to a single file. File name can be + set with ``-tofile:filename`` or ``-tofile:dir_name/filename``. -save Save Page content to a file named as :code:`page.title(as_filename=True)`. Directory can be set with ``-save:dir_name``. If no dir is specified, current directory will be used. --encode File encoding can be specified with '-encode:name' (name - must be a valid python encoding: utf-8, etc.). If not +-encode [str] File encoding can be specified with ``-encode:name`` + (name must be a valid python encoding: utf-8, etc.). If not specified, it defaults to :code:`config.textfile_encoding`. -put: [str] Save the list to the defined page of the wiki. By default it does not overwrite an existing page. --overwrite Overwrite the page if it exists. Can only by applied with +-overwrite Overwrite the page if it exists. Can only be applied with -put. -summary: [str] The summary text when the page is written. If it's one @@ -99,7 +101,7 @@ ¶ms; """ # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # From 8d1e922272794fdd6db23310c77b0aeefb7813df Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 21 Jul 2025 16:55:26 +0200 Subject: [PATCH 046/279] Update git submodules * Update scripts/i18n from branch 'master' to d562677e495b876fd839662ee5e34cdf8c279eae - Localisation updates from https://translatewiki.net. Change-Id: Iafd340850983203fbcc98c138d1cabbaa94c6d05 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 6a468b739c..d562677e49 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 6a468b739c1eaff0679ebaa02026ef3710224ad7 +Subproject commit d562677e495b876fd839662ee5e34cdf8c279eae From 7e4f3da8e4047beea14dc8b9a061d7195abd26a2 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 26 Jul 2025 12:59:37 +0200 Subject: [PATCH 047/279] Tests: Update pre-commit hooks Change-Id: I8d873e0c42b629d806b35bb47905b19285134eb4 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26a983d983..f7800b8a5c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.4 + rev: v0.12.5 hooks: - id: ruff-check alias: ruff From 67c8dbcd6c9694d22d949c83abc2060ff5089526 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 26 Jul 2025 12:50:53 +0200 Subject: [PATCH 048/279] Fix: add 'application/vue+xml' to MediaWikiKnownTypesTestCase Bug: T400537 Change-Id: I4b3470b1df436f1f06f1d3830c0ca3494e4aa70d --- tests/paraminfo_tests.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/paraminfo_tests.py b/tests/paraminfo_tests.py index a89bdcd677..388c0367aa 100755 --- a/tests/paraminfo_tests.py +++ b/tests/paraminfo_tests.py @@ -119,6 +119,7 @@ def test_content_format(self) -> None: 'text/css', 'text/plain', ] + if self.site.mw_version >= '1.36.0-wmf.2': base.extend([ 'application/octet-stream', @@ -127,6 +128,10 @@ def test_content_format(self) -> None: 'text/unknown', 'unknown/unknown', ]) + + if self.site.mw_version >= '1.45.0-wmf.11': + base.append('application/vue+xml') # T400537 + if isinstance(self.site, DataSite): # It is not clear when this format has been added, see T129281. base.append('application/vnd.php.serialized') From 234519c191abf702893677133519c71644050e3c Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 19 Jul 2025 17:37:59 +0200 Subject: [PATCH 049/279] doc: Clarify articlepath property docstring and replace assert with exception Change-Id: I95ae3889474c794422e29b0441fffe1c373c5fb2 --- pywikibot/site/_apisite.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py index 5098e40f78..9ee6fb4758 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -828,13 +828,23 @@ def get_searched_namespaces(self, force: bool = False) -> set[Namespace]: @property def articlepath(self) -> str: - """Get the nice article path with ``{}``placeholder. + """Return article path with a ``{}`` placeholder. + + Replaces the ``$1`` placeholder from MediaWiki with a + Python-compatible ``{}``. .. versionadded:: 7.0 + + .. versionchanged:: 10.3 + raises ValueError instead of AttributeError if "$1" + placeholder is missing. + + :raises ValueError: missing "$1" placeholder """ path = self.siteinfo['general']['articlepath'] - # Assert $1 placeholder is present - assert '$1' in path, 'articlepath must contain "$1" placeholder' + if '$1' not in path: + raise ValueError( + f'Invalid article path "{path}": missing "$1" placeholder') return path.replace('$1', '{}') @cached From c4cef9778ccb09c937508480296ee5f88b3b0b2a Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 17 Jul 2025 10:31:35 +0200 Subject: [PATCH 050/279] Cleanup: Deprecate create_isbn_edition script Bug: T398140 Change-Id: I48dd21e821ee3de96f0805724f8a0027693dd056 --- .codecov.yml | 1 + scripts/create_isbn_edition.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/.codecov.yml b/.codecov.yml index 322dcd039f..403b07313d 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -24,6 +24,7 @@ coverage: - pywikibot/families/__init__.py - pywikibot/scripts/preload_sites.py - pywikibot/scripts/version.py + - scripts/create_isbn_edition.py - scripts/maintenance/colors.py - scripts/maintenance/make_i18n_dict.py - scripts/userscripts/ diff --git a/scripts/create_isbn_edition.py b/scripts/create_isbn_edition.py index 684d4f4238..07d7d287cf 100755 --- a/scripts/create_isbn_edition.py +++ b/scripts/create_isbn_edition.py @@ -5,6 +5,12 @@ amend the related Wikidata item for edition (with the :samp:`P212, {ISBN number}` as unique external ID). +.. deprecated:: 10.3 + This script is deprecated and will be removed in Pywikibot 11.0. + An external version of this script can be found in the + `geertivp/Pywikibot `_ script + collection. See :phab:`T398140` for details. + Use digital libraries to get ISBN data in JSON format, and integrate the results into Wikidata. From 304208dfe07e1d39f3a74b5c7e1fdee08eccb6ad Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 26 Jul 2025 17:05:21 +0200 Subject: [PATCH 051/279] Fix: Use {{talkarchive}} template by default If there is no localized template found on wikibase, use {{talkarchive}} as default. I such case the archiveheader parameter is recommended instead. Update documentation. Bug: T400543 Change-Id: Ibb938bbf9c9ed0a2fdd7ab07e968099229cfb383 --- scripts/archivebot.py | 174 +++++++++++++++++++++++++++--------------- 1 file changed, 112 insertions(+), 62 deletions(-) diff --git a/scripts/archivebot.py b/scripts/archivebot.py index a19917557c..6b761651e6 100755 --- a/scripts/archivebot.py +++ b/scripts/archivebot.py @@ -1,21 +1,24 @@ #!/usr/bin/env python3 -"""archivebot.py - discussion page archiving bot. +"""archivebot.py - Discussion page archiving bot. usage: python pwb.py archivebot [OPTIONS] [TEMPLATE_PAGE] -Several TEMPLATE_PAGE templates can be given at once. Default is -`User:MiszaBot/config`. Bot examines backlinks (Special:WhatLinksHere) -to all TEMPLATE_PAGE templates. Then goes through all pages (unless a -specific page specified using options) and archives old discussions. -This is done by breaking a page into threads, then scanning each thread -for timestamps. Threads older than a specified threshold are then moved -to another page (the archive), which can be named either basing on the -thread's name or then name can contain a counter which will be -incremented when the archive reaches a certain size. +Multiple TEMPLATE_PAGE templates can be given in a single command. The +default is ``User:MiszaBot/config``. The bot examines backlinks (i.e. +Special:WhatLinksHere) to all given TEMPLATE_PAGE templates. It then +processes those pages (unless a specific page is specified via options) +and archives old discussions. -Transcluded template may contain the following parameters: +This is done by splitting each page into threads and scanning them for +timestamps. Threads older than a configured threshold are moved to an +archive page. The archive page name can be based on the thread's title, +or include a counter that increments when the archive reaches a +configured size. + +The transcluded configuration template may include the following +parameters: .. code:: wikitext @@ -30,42 +33,59 @@ |key = }} -Meanings of parameters are: +**Parameters meanings:** archive - Name of the page to which archived threads will be put. Must be a - subpage of the current page. Variables are supported. + Name of the archive page where threads will be moved. Must be a + subpage of the current page, unless a valid ``key`` is provided. + Supports variables. + algo - Specifies the maximum age of a thread. Must be in the form - :code:`old()` where ```` specifies the age in - seconds (s), hours (h), days (d), weeks (w), or years (y) like ``24h`` - or ``5d``. Default is :code:`old(24h)`. + Specifies the maximum age of a thread using the syntax: + :code:`old()`, where ```` can be in seconds (s), hours (h), + days (d), weeks (w), or years (y). For example: ``24h`` or ``5d``. + Default: :code:`old(24h)`. + counter - The current value of a counter which could be assigned as variable. - Will be updated by bot. Initial value is 1. + The current value of the archive counter used in archive page naming. + Will be updated automatically by the bot. Default: 1. + maxarchivesize - The maximum archive size before incrementing the counter. Value can - be given with appending letter like ``K`` or ``M`` which indicates - KByte or MByte. Default value is ``200K``. + The maximum size of an archive page before incrementing the counter. + A suffix of ``K`` or ``M`` may be used for kilobytes or megabytes. + Default: ``200K``. + minthreadsleft - Minimum number of threads that should be left on a page. Default - value is 5. + Minimum number of threads that must remain on the main page after + archiving. Default: 5. + minthreadstoarchive - The minimum number of threads to archive at once. Default value is 2. + Minimum number of threads that must be eligible for archiving before + any are moved. Default: 2. + archiveheader - Content that will be put on new archive pages as the header. This - parameter supports the use of variables. Default value is - ``{{talkarchive}}``. + Content placed at the top of each newly created archive page. + Supports variables. If not set explicitly, a localized default will + be retrieved from Wikidata using known archive header templates. If + no localized template is found, the fallback ``{{talkarchive}}`` is + used. + + .. note:: + If no ``archiveheader`` is set and no localized template can be + retrieved from Wikidata, the fallback ``{{talkarchive}}`` is used. + This generic fallback may not be appropriate for all wikis, so it + is recommended to set ``archiveheader`` explicitly in such cases. + key - A secret key that (if valid) allows archives not to be subpages of - the page being archived. + A secret key that, if valid, allows archive pages to exist outside + of the subpage structure of the current page. -Variables below can be used in the value for "archive" in the template -above; numbers are **ascii** digits. Alternatively you may use -**localized** digits. This is only available for a few site languages. -Refer :attr:`NON_ASCII_DIGITS -` whether there is a -localized one. +Variables below can be used in the value of the "archive" parameter in +the template above. Numbers are represented as **ASCII** digits by +default; alternatively, **localized** digits may be used. Localized +digits are only available for a few site languages. Please refer to +:attr:`NON_ASCII_DIGITS ` +to check if a localized version is available. .. list-table:: :header-rows: 1 @@ -104,13 +124,17 @@ - %(localweek)s - week number of the thread being archived -The ISO calendar starts with the Monday of the week which has at least -four days in the new Gregorian calendar. If January 1st is between -Monday and Thursday (including), the first week of that year started the -Monday of that week, which is in the year before if January 1st is not a -Monday. If it's between Friday or Sunday (including) the following week -is then the first week of the year. So up to three days are still -counted as the year before. +The ISO calendar defines the first week of the year as the week +containing the first Thursday of the Gregorian calendar year. This means: + +- If January 1st falls on a Monday, Tuesday, Wednesday, or Thursday, then + the week containing January 1st is considered the first week of the year. + +- If January 1st falls on a Friday, Saturday, or Sunday, then the first ISO + week starts on the following Monday. + +Because of this, up to three days at the start of January can belong to the +last week of the previous year according to the ISO calendar. .. seealso:: Python :python:`datetime.date.isocalendar `, @@ -118,36 +142,47 @@ Options (may be omitted): --help show this help message and exit +-help Show this help message and exit. --calc:PAGE calculate key for PAGE and exit +-calc:PAGE Calculate key for PAGE and exit. --file:FILE load list of pages from FILE +-file:FILE Load list of pages from FILE. --force override security options +-force Override security options. --locale:LOCALE switch to locale LOCALE +-locale:LOCALE Switch to locale LOCALE. --namespace:NS only archive pages from a given namespace +-namespace:NS Only archive pages from the given namespace. --page:PAGE archive a single PAGE, default ns is a user talk page +-page:PAGE Archive a single PAGE. Default namespace is a user talk + page. --salt:SALT specify salt +-salt:SALT Specify salt. -keep Preserve thread order in archive even if threads are - archived later --sort Sort archive by timestamp; should not be used with `keep` + archived later. + +-sort Sort archive by timestamp; should not be used with `keep`. -async Run the bot in parallel tasks. +Version historty: + .. versionchanged:: 7.6 - Localized variables for "archive" template parameter are supported. - `User:MiszaBot/config` is the default template. `-keep` option was - added. + Localized variables for the ``archive`` parameter are supported. + ``User:MiszaBot/config`` is the default template. The ``-keep`` option + was added. + .. versionchanged:: 7.7 ``-sort`` and ``-async`` options were added. + .. versionchanged:: 8.2 - KeyboardInterrupt was enabled with ``-async`` option. + KeyboardInterrupt support added when using the ``-async`` option. + +.. versionchanged:: 10.3 + If ``archiveheader`` is not set, the bot now attempts to retrieve a + localized template from Wikidata (based on known item IDs). If none is + found, ``{{talkarchive}}`` is used as fallback. """ # # (C) Pywikibot team, 2006-2025 @@ -395,19 +430,34 @@ def max( return max(ts1, ts2) def get_header_template(self) -> str: - """Get localized archive header template. + """Return a localized archive header template from Wikibase. + + This method looks up a localized archive header template by + checking a predefined list of Wikidata item IDs that correspond + to commonly used archive header templates. It returns the first + matching template found on the local wiki via the site’s + Wikibase repository. + + If no such localized template is found, it falls back to the + default ``{{talkarchive}}`` template. .. versionadded:: 10.2 - :raises NotImplementedError: Archive header is not localized + .. versionchanged:: 10.3 + Returns ``{{talkarchive}}`` by default if no localized + template is found. + + .. caution:: + The default should be avoided where possible. It is + recommended to explicitly set the ``archiveheader`` parameter + in the bot's configuration template instead. """ for item in ARCHIVE_HEADER: tpl = self.site.page_from_repository(item) if tpl: return f'{{{{{tpl.title(with_ns=False)}}}}}' - raise NotImplementedError( - 'Archive header is not localized on your site') + return '{{talkarchive}}' def load_page(self) -> None: """Load the page to be archived and break it up into threads. From 387bde4524d146b4360f8addf7e4ae17cba4757a Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 26 Jul 2025 18:24:54 +0200 Subject: [PATCH 052/279] Tests: Suppress print statements during generate_family_file_tests.py Suppress print statements in FamilyFileGenerator.run() and getapis() methods. These methods produce unnecessary interactive output that floods test results. Suppressing them improves test readability. Change-Id: I37ecee8329ab455dadaf4feace541030bc9e56e2 --- pywikibot/scripts/generate_family_file.py | 43 +++++++++++++---------- tests/generate_family_file_tests.py | 5 ++- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/pywikibot/scripts/generate_family_file.py b/pywikibot/scripts/generate_family_file.py index 4e3bde05e6..f09046d2f4 100755 --- a/pywikibot/scripts/generate_family_file.py +++ b/pywikibot/scripts/generate_family_file.py @@ -43,6 +43,7 @@ import sys from contextlib import suppress from pathlib import Path +from textwrap import fill from urllib.parse import urlparse, urlunparse @@ -96,6 +97,11 @@ def __init__(self, self.wikis = {} # {'https://wiki/$1': Wiki('https://wiki/$1'), ...} self.langs = [] # [Wiki('https://wiki/$1'), ...] + @staticmethod + def show(*args, **kwargs): + """Wrapper around print to be mocked in tests.""" + print(*args, **kwargs) + def get_params(self) -> bool: # pragma: no cover """Ask for parameters if necessary.""" if self.base_url is None: @@ -117,8 +123,8 @@ def get_params(self) -> bool: # pragma: no cover return False if any(x not in NAME_CHARACTERS for x in self.name): - print(f'ERROR: Name of family "{self.name}" must be ASCII letters' - ' and digits [a-zA-Z0-9]') + self.show(f'ERROR: Name of family "{self.name}" must be ASCII' + ' letters and digits [a-zA-Z0-9]') return False return True @@ -153,10 +159,10 @@ def run(self) -> None: return self.wikis[w.lang] = w - print('\n==================================' - f'\nAPI url: {w.api}' - f'\nMediaWiki version: {w.version}' - '\n==================================\n') + self.show('\n==================================' + f'\nAPI url: {w.api}' + f'\nMediaWiki version: {w.version}' + '\n==================================\n') self.getlangs(w) self.getapis() @@ -171,13 +177,14 @@ def getlangs(self, w) -> None: same domain are collected. A [h]elp answer was added to show more information about possible answers. """ - print('Determining other sites...', end='') + self.show('Determining other sites...', end='') try: self.langs = w.langs - print(' '.join(sorted(wiki['prefix'] for wiki in self.langs))) + self.show(fill(' '.join(sorted(wiki['prefix'] + for wiki in self.langs)))) except Exception as e: # pragma: no cover self.langs = [] - print(e, '; continuing...') + self.show(e, '; continuing...') if len([lang for lang in self.langs if lang['url'] == w.iwpath]) == 0: if w.private_wiki: @@ -199,7 +206,7 @@ def getlangs(self, w) -> None: '([y]es, [s]trict, [N]o, [e]dit), [h]elp) ').lower() if makeiw in ('y', 's', 'n', 'e', ''): break - print( + self.show( '\n' '[y]es: create interwiki links for all sites\n' '[s]trict: yes, but for sites with same domain only\n' @@ -220,7 +227,7 @@ def getlangs(self, w) -> None: elif makeiw == 'e': # pragma: no cover for wiki in self.langs: - print(wiki['prefix'], wiki['url']) + self.show(wiki['prefix'], wiki['url']) do_langs = re.split(' *,| +', input('Which sites do you want: ')) self.langs = [wiki for wiki in self.langs @@ -235,20 +242,20 @@ def getlangs(self, w) -> None: def getapis(self) -> None: """Load other site pages.""" - print(f'Loading {len(self.langs)} wikis... ') + self.show(f'Loading {len(self.langs)} wikis... ') remove = [] for lang in self.langs: key = lang['prefix'] - print(f' * {key}... ', end='') + self.show(f' * {key}... ', end='') if key not in self.wikis: try: self.wikis[key] = self.Wiki(lang['url']) - print('downloaded') + self.show('downloaded') except Exception as e: # pragma: no cover - print(e) + self.show(e) remove.append(lang) else: - print('in cache') + self.show('in cache') for lang in remove: self.langs.remove(lang) @@ -256,11 +263,11 @@ def getapis(self) -> None: def writefile(self, verify) -> None: # pragma: no cover """Write the family file.""" fp = Path(self.base_dir, 'families', f'{self.name}_family.py') - print(f'Writing {fp}... ') + self.show(f'Writing {fp}... ') if fp.exists() and input( f'{fp} already exists. Overwrite? (y/n) ').lower() == 'n': - print('Terminating.') + self.show('Terminating.') sys.exit(1) code_hostname_pairs = '\n '.join( diff --git a/tests/generate_family_file_tests.py b/tests/generate_family_file_tests.py index 91b86fc4cc..e7386a67c5 100755 --- a/tests/generate_family_file_tests.py +++ b/tests/generate_family_file_tests.py @@ -10,6 +10,7 @@ import unittest from contextlib import suppress from random import sample +from unittest.mock import patch from urllib.parse import urlparse from pywikibot import Site @@ -86,7 +87,9 @@ def test_initial_attributes(self) -> None: def test_attributes_after_run(self) -> None: """Test FamilyFileGenerator attributes after run().""" gen = self.generator_instance - gen.run() + with patch.object(FamilyTestGenerator, 'show') as mock_show: + gen.run() + mock_show.assert_called() with self.subTest(test='Test whether default is loaded'): self.assertIn(self.site.lang, gen.wikis) From d6c599533de3d6a7da0ff1c8c7cbb2374fddc745 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 26 Jul 2025 19:07:18 +0200 Subject: [PATCH 053/279] Tests: ignore create_isbn_edition from script_tests Also refactor ist_scripts function using pathlib; exclude must not contain .py suffix anymore. Bug: T398140 Change-Id: I76015f105c9d5af0e1bc4c5008b3bf1660c27ff5 --- tests/script_tests.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/script_tests.py b/tests/script_tests.py index 19e4eedaf4..ecf871ec17 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -12,6 +12,7 @@ import unittest from contextlib import suppress from importlib import import_module +from pathlib import Path from pywikibot.tools import has_module from tests import join_root_path, unittest_print @@ -28,7 +29,6 @@ # These dependencies are not always the package name which is in setup.py. # Here, the name given to the module which will be imported is required. script_deps = { - 'create_isbn_edition': ['isbnlib', 'unidecode'], 'weblinkchecker': ['memento_client'], } @@ -51,17 +51,28 @@ def check_script_deps(script_name) -> bool: unrunnable_script_set = set() -def list_scripts(path, exclude=None): - """Return list of scripts in given path.""" +def list_scripts(path: str, exclude: str = '') -> list[str]: + """List script names (without '.py') in a directory. + + :param path: Directory path to search for Python scripts. + :param exclude: Filename (without '.py' extension) to exclude from + the result. Defaults to empty string, meaning no exclusion. + :return: List of script names without the '.py' extension, excluding + the specified file. Files starting with '_' (e.g. __init__.py) + are always excluded. + """ + p = Path(path) return [ - name[0:-3] for name in os.listdir(path) # strip '.py' - if name.endswith('.py') - and not name.startswith('_') # skip __init__.py and _* - and name != exclude + f.stem for f in p.iterdir() + if f.is_file() + and f.suffix == '.py' + and not f.name.startswith('_') + and f.stem != exclude ] -script_list = framework_scripts + list_scripts(scripts_path) +script_list = framework_scripts + list_scripts(scripts_path, + 'create_isbn_edition') script_input = { 'create_isbn_edition': '\n', From c2f24442a87c851bb91d136239a3080e670562ee Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 26 Jul 2025 20:25:33 +0200 Subject: [PATCH 054/279] Fix coverage excludes Bug: T400546 Change-Id: I26885a8b6e529943885f9c651fc15bbf9eaec098 --- pyproject.toml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9ac05830ba..00856f04a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,7 +123,7 @@ Tracker = "https://phabricator.wikimedia.org/tag/pywikibot/" ignore_errors = true skip_empty = true -exclude_other = [ +exclude_also = [ # Have to re-enable the standard pragma "except ImportError", "except KeyboardInterrupt", @@ -140,10 +140,6 @@ exclude_other = [ "@(abc\\.)?abstractmethod", "@deprecated\\([^\\)]+\\)", "@unittest\\.skip", - "no cover: start(?s:.)*?no cover: stop", -] - -exclude_also = [ # Comments to turn coverage on and off: "no cover: start(?s:.)*?no cover: stop", ] From d051965df66c46f5f7eb749b238c94ffb0020ab2 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 27 Jul 2025 10:56:24 +0200 Subject: [PATCH 055/279] interwiki: Fix docstring of Subject.process_unlimited Change-Id: Id70d689aab758acab7128425a46247064e8973f1 --- scripts/interwiki.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/interwiki.py b/scripts/interwiki.py index abbde43f14..473daac597 100755 --- a/scripts/interwiki.py +++ b/scripts/interwiki.py @@ -1513,7 +1513,7 @@ def process_limit_two(self, new, updated) -> None: break def process_unlimited(self, new, updated) -> None: - """"Post-process pages: replace links and track updated sites.""" + """Post-process pages: replace links and track updated sites.""" for site, page in new.items(): if site.has_data_repository: self.conf.note( From 54ac00b0e3d46deb8dd65163b1aa1429a6bde156 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 27 Jul 2025 15:02:49 +0200 Subject: [PATCH 056/279] Coverage: Update exclude_also list to skip and skip Python314AssertionsMixin Change-Id: I438647734891dcd12e9b9c69bdc2f9918f01ee46 --- pyproject.toml | 23 +++++++++++++---------- tests/aspects.py | 4 ++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 00856f04a9..fbfdc1aa84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,24 +124,27 @@ ignore_errors = true skip_empty = true exclude_also = [ - # Have to re-enable the standard pragma + "@(abc\\.)?abstractmethod", + "@deprecated\\([^\\)]+\\)", + "@unittest\\.skip", + "class .+\\bProtocol\\):", "except ImportError", "except KeyboardInterrupt", "except OSError", - "except \\w*ServerError", "except SyntaxError", - "raise ImportError", - "raise NotImplementedError", - "raise unittest\\.SkipTest", - "self\\.skipTest", - "if __name__ == .__main__.:", + "except \\w*ServerError", + "if (0|False):", "if .+PYWIKIBOT_TEST_\\w+.+:", + "if TYPE_CHECKING:", + "if __debug__:", + "if __name__ == .__main__.:", "if self\\.mw_version < .+:", - "@(abc\\.)?abstractmethod", - "@deprecated\\([^\\)]+\\)", - "@unittest\\.skip", # Comments to turn coverage on and off: "no cover: start(?s:.)*?no cover: stop", + "raise ImportError", + "raise NotImplementedError", + "raise unittest\\.SkipTest", + "self\\.skipTest", ] diff --git a/tests/aspects.py b/tests/aspects.py index 41f6e6ecd0..0a29a4a0a6 100644 --- a/tests/aspects.py +++ b/tests/aspects.py @@ -65,7 +65,7 @@ pywikibot.bot.set_interface('buffer') -class Python314AssertionsMixin: +class Python314AssertionsMixin: # pragma: no cover """Mixin providing assertion methods added in Python 3.14 for unittest. @@ -779,7 +779,7 @@ def _reset_login(self, skip_if_login_fails: bool = False) -> None: continue if not site.logged_in(): - site.login() + site.login() # pragma: no cover if skip_if_login_fails and not site.user(): # during setUp() only self.skipTest( From 6e422c4f8d447d6333264f19eb25247c393e23e1 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 27 Jul 2025 15:38:50 +0200 Subject: [PATCH 057/279] Cleanup: remove Flow tests parts Bug: T107537 Bug: T381551 Change-Id: I1c2e1684fe3c863e9d09369ddda274e9dd55e4c7 --- tests/basepage.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/basepage.py b/tests/basepage.py index 651569b06d..975d30ef95 100644 --- a/tests/basepage.py +++ b/tests/basepage.py @@ -40,7 +40,7 @@ def setUp(self) -> None: super().setUp() assert self.cached is False, 'Tests do not support caching' - def _test_page_text(self, get_text=True) -> None: + def _test_page_text(self) -> None: """Test site.loadrevisions() with .text.""" page = self._page @@ -84,23 +84,16 @@ def _test_page_text(self, get_text=True) -> None: loadrevisions = self.site.loadrevisions try: self.site.loadrevisions = None - if get_text: - loaded_text = page.text - else: # T107537 - with self.assertRaises(NotImplementedError): - page.text - loaded_text = '' + loaded_text = page.text self.assertIsNotNone(loaded_text) self.assertNotHasAttr(page, '_text') page.text = custom_text - if get_text: - self.assertEqual(page.get(), loaded_text) + self.assertEqual(page.get(), loaded_text) self.assertEqual(page._text, custom_text) self.assertEqual(page.text, page._text) del page.text self.assertNotHasAttr(page, '_text') - if get_text: - self.assertEqual(page.text, loaded_text) + self.assertEqual(page.text, loaded_text) finally: self.site.loadrevisions = loadrevisions From 948de4fdb8e21127209dd71e7502b85e60017117 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 27 Jul 2025 14:18:58 +0200 Subject: [PATCH 058/279] typing: Remove typing_extension; Literal is published with Python 3.8 Change-Id: I8b3c4580e00d4164e6522c9a8a6c6830db113972 --- pywikibot/bot_choice.py | 6 +++--- pywikibot/page/_basepage.py | 4 ++-- pywikibot/pagegenerators/_factory.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pywikibot/bot_choice.py b/pywikibot/bot_choice.py index f4ced790e8..23785e297d 100644 --- a/pywikibot/bot_choice.py +++ b/pywikibot/bot_choice.py @@ -1,6 +1,6 @@ """Options and Choices for :py:meth:`pywikibot.input_choice`.""" # -# (C) Pywikibot team, 2015-2024 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -9,7 +9,7 @@ import re from abc import ABC, abstractmethod from textwrap import fill -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import pywikibot from pywikibot.backports import Iterable, Mapping, Sequence @@ -39,7 +39,7 @@ if TYPE_CHECKING: - from typing_extensions import Literal + from typing import Any, Literal from pywikibot.page import BaseLink, Link, Page diff --git a/pywikibot/page/_basepage.py b/pywikibot/page/_basepage.py index c3f9678b54..39bc1a07fa 100644 --- a/pywikibot/page/_basepage.py +++ b/pywikibot/page/_basepage.py @@ -12,7 +12,7 @@ from contextlib import suppress from itertools import islice from textwrap import shorten, wrap -from typing import TYPE_CHECKING, Any, NoReturn +from typing import TYPE_CHECKING from urllib.parse import quote_from_bytes from warnings import warn @@ -46,7 +46,7 @@ if TYPE_CHECKING: - from typing_extensions import Literal + from typing import Any, Literal, NoReturn from pywikibot.page import Revision diff --git a/pywikibot/pagegenerators/_factory.py b/pywikibot/pagegenerators/_factory.py index 3c076def4e..249466d51c 100644 --- a/pywikibot/pagegenerators/_factory.py +++ b/pywikibot/pagegenerators/_factory.py @@ -1,6 +1,6 @@ """GeneratorFactory module which handles pagegenerators options.""" # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -12,7 +12,7 @@ from datetime import timedelta from functools import partial from itertools import zip_longest -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import pywikibot from pywikibot import i18n @@ -62,7 +62,7 @@ if TYPE_CHECKING: - from typing_extensions import Literal + from typing import Any, Literal from pywikibot.site import BaseSite, Namespace From ea512a9bf79500c5a03b27cc81e82caa6457fde8 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 23 Jul 2025 09:35:36 +0200 Subject: [PATCH 059/279] doc: update deprecation lists Change-Id: If649e2f8f51d1fd661b11353f8e1783e4d01c632 --- ROADMAP.rst | 11 +++++++++-- pywikibot/data/api/_generators.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index fef751015b..7885f2fab5 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -58,6 +58,11 @@ Pending removal in Pywikibot 12 in favour of :meth:`DataSite.get_property_type()` * 9.3.0: :meth:`page.BasePage.userName` and :meth:`page.BasePage.isIpEdit` are deprecated in favour of ``user`` or ``anon`` attributes of :attr:`page.BasePage.latest_revision` property +* 9.3.0: *botflag* parameter of :meth:`Page.save()`, :meth:`Page.put() + `, :meth:`Page.touch()` and + :meth:`Page.set_redirect_target()` was renamed to *bot* +* 9.2.0: All parameters of :meth:`Page.templates` and + :meth:`Page.itertemplates()` must be given as keyworded arguments * 9.2.0: Imports of :mod:`logging` functions from the :mod:`bot` module are deprecated and will be desupported * 9.2.0: *total* argument in ``-logevents`` pagegenerators option is deprecated; use ``-limit`` instead (:phab:`T128981`) @@ -84,14 +89,16 @@ Pending removal in Pywikibot 12 Pending removal in Pywikibot 11 ------------------------------- +* 8.4.0: :attr:`data.api.QueryGenerator.continuekey` will be removed in favour of + :attr:`data.api.QueryGenerator.modules` * 8.4.0: The *modules_only_mode* parameter in the :class:`data.api.ParamInfo` class, its *paraminfo_keys* class attribute, and its ``preloaded_modules`` property will be removed * 8.4.0: The *dropdelay* and *releasepid* attributes of the :class:`throttle.Throttle` class will be removed in favour of the *expiry* class attribute * 8.2.0: The :func:`tools.itertools.itergroup` function will be removed in favour of the :func:`backports.batched` function -* 8.2.0: The *normalize* parameter in the :meth:`WbTime.toTimestr` and :meth:`WbTime.toWikibase` - methods will be removed +* 8.2.0: The *normalize* parameter in the :meth:`pywikibot.WbTime.toTimestr` and + :meth:`pywikibot.WbTime.toWikibase` methods will be removed * 8.1.0: The inheritance of the :exc:`exceptions.NoSiteLinkError` exception from :exc:`exceptions.NoPageError` will be removed * 8.1.0: The ``exceptions.Server414Error`` exception is deprecated in favour of the diff --git a/pywikibot/data/api/_generators.py b/pywikibot/data/api/_generators.py index 09ab0e2ed3..477dcb7e4b 100644 --- a/pywikibot/data/api/_generators.py +++ b/pywikibot/data/api/_generators.py @@ -314,7 +314,7 @@ def __init__(self, **kwargs) -> None: self._add_slots() @property - @deprecated(since='8.4.0') + @deprecated('modules', since='8.4.0') def continuekey(self) -> list[str]: """Return deprecated continuekey which is self.modules.""" return self.modules From f0c50fad868746a93d11e10b046672645475dbdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Harald=20S=C3=B8by?= Date: Wed, 16 Jul 2025 11:31:57 +0200 Subject: [PATCH 060/279] Add script for removing tracking URL parameters Bug: T399698 Depends-On: Ie8b65589b89c406e27b2b3ac837a2526565f955a Change-Id: I7469a8abfc9f76978611338a310ce8470cf4f9e9 --- scripts/tracking_param_remover.py | 141 ++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100755 scripts/tracking_param_remover.py diff --git a/scripts/tracking_param_remover.py b/scripts/tracking_param_remover.py new file mode 100755 index 0000000000..623f1cd6e8 --- /dev/null +++ b/scripts/tracking_param_remover.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Script to remove tracking URL query parameters from external URLs. + +These command line parameters can be used to specify which pages to work +on: + +¶ms; + +Furthermore, the following command line parameters are supported: + +-always Don't prompt for each removal + +.. versionadded:: 10.3 +""" +# +# (C) Pywikibot team, 2025 +# +# Distributed under the terms of the MIT license. +# +from __future__ import annotations + +import re +import urllib + +import mwparserfromhell + +import pywikibot +from pywikibot import pagegenerators +from pywikibot.bot import AutomaticTWSummaryBot, ExistingPageBot, SingleSiteBot + + +docuReplacements = { # noqa: N816 + '¶ms;': pagegenerators.parameterHelp, +} + +KNOWN_TRACKER_PARAMS = [ + 'utm_.+', # universal + 'fbclid', # Facebook + 'gad_.+', # Google + 'gclid', # Google + '[gw]braid', # Google + 'li_fat_id', # LinkedIn + 'mc_.+', # Mailchimp + 'pk_.+', # Matomo / Piwik + 'msclkid', # Microsoft + 'epik', # Pinterest + 'scid', # Snapchat + 'ttclid', # TikTok + 'twclid', # Twitter / X + 'vero_.+', # Vero + 'wprov', # Wikimedia / MediaWiki + '_openstat', # Yandex + 'yclid', # Yandex + 'si', # YouTube, Spotify +] + +KNOWN_TRACKER_REGEX = re.compile(rf'({"|".join(KNOWN_TRACKER_PARAMS)})') + + +class TrackingParamRemoverBot( + SingleSiteBot, + AutomaticTWSummaryBot, + ExistingPageBot +): + + """Bot to remove tracking URL parameters.""" + + summary_key = 'tracking_param_remover-removing' + + @staticmethod + def remove_tracking_params(url: urllib.parse.ParseResult) -> str: + """Remove tracking query parameters if they are present. + + :param url: The URL to check + :returns: URL as string + """ + filtered_params = [] + + tracker_present = False + for k, v in urllib.parse.parse_qsl(url.query, keep_blank_values=True): + if KNOWN_TRACKER_REGEX.fullmatch(k): + tracker_present = True + else: + filtered_params.append((k, v)) + + if not tracker_present: + # Return the original URL if no tracker parameters were present + return urllib.parse.urlunparse(url) + + new_query = urllib.parse.urlencode(filtered_params) + + new_url = urllib.parse.urlunparse(url._replace(query=new_query)) + + return new_url + + def treat_page(self) -> None: + """Treat a page.""" + wikicode = mwparserfromhell.parse(self.current_page.text) + + for link in wikicode.ifilter_external_links(): + parsed_url = urllib.parse.urlparse(str(link.url)) + if not parsed_url.query: + continue + tracking_params_removed = self.remove_tracking_params(parsed_url) + if urllib.parse.urlunparse(parsed_url) == tracking_params_removed: + # Continue if no parameters were removed + continue + wikicode.replace(link.url, tracking_params_removed) + + self.put_current(wikicode) + + +def main(*args: str) -> None: + """Process command line arguments and invoke bot. + + If args is an empty list, sys.argv is used. + + :param args: command line arguments + """ + options = {} + + # Process global args and prepare generator args parser + local_args = pywikibot.handle_args(args) + gen_factory = pagegenerators.GeneratorFactory() + script_args = gen_factory.handle_args(local_args) + + for arg in script_args: + opt, _, value = arg.partition(':') + if opt == '-always': + options['always'] = True + + site = pywikibot.Site() + + gen = gen_factory.getCombinedGenerator(preload=True) + bot = TrackingParamRemoverBot(generator=gen, **options) + site.login() + bot.run() + + +if __name__ == '__main__': + main() From 4f9ba84179861d52d205638aeddbe855d3545352 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 27 Jul 2025 16:47:54 +0200 Subject: [PATCH 061/279] Tests: Enforce setup_page() in BasePageTestBase and improve test structure - Make BasePageTestBase abstract by replacing ABC inheritance with a combined metaclass of ABCMeta and TestCase metaclass to resolve conflicts - Enforce setup_page() implementation as an abstract method in the base class - Ensure all concrete test classes implement setup_page() and define test_ methods calling helpers - Use assert methods instead of bare assert statements in setup Change-Id: I0fc910d0f192dfc57427e06d86e47eff6c8aa61a --- tests/basepage.py | 32 ++++++++++++++++++++++++++------ tests/proofreadpage_tests.py | 23 ++++++++++------------- tests/site_tests.py | 5 ++--- tests/wikibase_tests.py | 13 +++++-------- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/tests/basepage.py b/tests/basepage.py index 975d30ef95..a611768ab6 100644 --- a/tests/basepage.py +++ b/tests/basepage.py @@ -6,11 +6,21 @@ # from __future__ import annotations +from abc import ABCMeta, abstractmethod + from pywikibot.page import BasePage from tests.aspects import TestCase -class BasePageTestBase(TestCase): +class ABCTestCaseMeta(ABCMeta, type(TestCase)): + + """Enable abstract methods in TestCase-based base classes. + + .. versionadded:: 10.3 + """ + + +class BasePageTestBase(TestCase, metaclass=ABCTestCaseMeta): """Base of BasePage test classes.""" @@ -19,8 +29,17 @@ class BasePageTestBase(TestCase): def setUp(self) -> None: """Set up test.""" super().setUp() - assert self._page, 'setUp() must create an empty BasePage in _page' - assert isinstance(self._page, BasePage) + self.setup_page() + self.assertIsInstance(self._page, BasePage, + 'setUp() must assign a BasePage to _page, not ' + f'{type(self._page).__name__}') + + @abstractmethod + def setup_page(self) -> None: + """Subclasses must implement this to assign self._page. + + .. versionadded:: 10.3 + """ class BasePageLoadRevisionsCachingTestBase(BasePageTestBase): @@ -38,7 +57,7 @@ class BasePageLoadRevisionsCachingTestBase(BasePageTestBase): def setUp(self) -> None: """Set up test.""" super().setUp() - assert self.cached is False, 'Tests do not support caching' + self.assertFalse(self.cached, 'Tests do not support caching') def _test_page_text(self) -> None: """Test site.loadrevisions() with .text.""" @@ -61,9 +80,9 @@ def _test_page_text(self) -> None: self.assertHasAttr(page, '_revisions') self.assertLength(page._revisions, 1) self.assertIn(page._revid, page._revisions) - self.assertEqual(page._text, custom_text) self.assertEqual(page.text, page._text) + del page.text self.assertNotHasAttr(page, '_text') @@ -71,13 +90,14 @@ def _test_page_text(self) -> None: self.assertIsNone(page._latest_cached_revision()) page.text = custom_text - self.site.loadrevisions(page, total=1, content=True) self.assertIsNotNone(page._latest_cached_revision()) self.assertEqual(page._text, custom_text) self.assertEqual(page.text, page._text) + del page.text + self.assertNotHasAttr(page, '_text') # Verify that calling .text doesn't call loadrevisions again diff --git a/tests/proofreadpage_tests.py b/tests/proofreadpage_tests.py index 4d908d928f..1e6d481db1 100755 --- a/tests/proofreadpage_tests.py +++ b/tests/proofreadpage_tests.py @@ -142,11 +142,11 @@ class TestBasePageMethodsProofreadPage(BasePageMethodsTestBase): family = 'wikisource' code = 'en' - def setUp(self) -> None: - """Set up test case.""" + def setup_page(self) -> None: + """Set up test page.""" self._page = ProofreadPage( - self.site, 'Page:Popular Science Monthly Volume 1.djvu/12') - super().setUp() + self.site, 'Page:Popular Science Monthly Volume 1.djvu/12' + ) def test_basepage_methods(self) -> None: """Test ProofreadPage methods inherited from superclass BasePage.""" @@ -162,11 +162,10 @@ class TestLoadRevisionsCachingProofreadPage( family = 'wikisource' code = 'en' - def setUp(self) -> None: - """Set up test case.""" + def setup_page(self) -> None: + """Set up test page.""" self._page = ProofreadPage( self.site, 'Page:Popular Science Monthly Volume 1.djvu/12') - super().setUp() def test_page_text(self) -> None: """Test site.loadrevisions() with Page.text.""" @@ -637,11 +636,10 @@ class TestBasePageMethodsIndexPage(BS4TestCase, BasePageMethodsTestBase): family = 'wikisource' code = 'en' - def setUp(self) -> None: - """Set up test case.""" + def setup_page(self) -> None: + """Set up test page.""" self._page = IndexPage( self.site, 'Index:Popular Science Monthly Volume 1.djvu') - super().setUp() def test_basepage_methods(self) -> None: """Test IndexPage methods inherited from superclass BasePage.""" @@ -657,11 +655,10 @@ class TestLoadRevisionsCachingIndexPage(BS4TestCase, family = 'wikisource' code = 'en' - def setUp(self) -> None: - """Set up test case.""" + def setup_page(self) -> None: + """Set up test page.""" self._page = IndexPage( self.site, 'Index:Popular Science Monthly Volume 1.djvu') - super().setUp() def test_page_text(self) -> None: """Test site.loadrevisions() with Page.text.""" diff --git a/tests/site_tests.py b/tests/site_tests.py index b061b18467..937cccb8ec 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -823,10 +823,9 @@ class TestSiteLoadRevisionsCaching(BasePageLoadRevisionsCachingTestBase, """Test site.loadrevisions() caching.""" - def setUp(self) -> None: - """Setup tests.""" + def setup_page(self) -> None: + """Setup test page.""" self._page = self.get_mainpage(force=True) - super().setUp() def test_page_text(self) -> None: """Test site.loadrevisions() with Page.text.""" diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py index b07adb15c1..4d1d77fa4d 100755 --- a/tests/wikibase_tests.py +++ b/tests/wikibase_tests.py @@ -50,10 +50,9 @@ class TestLoadRevisionsCaching(BasePageLoadRevisionsCachingTestBase, """Test site.loadrevisions() caching.""" - def setUp(self) -> None: - """Setup test.""" + def setup_page(self) -> None: + """Setup test page.""" self._page = ItemPage(self.get_repo(), 'Q15169668') - super().setUp() def test_page_text(self) -> None: """Test site.loadrevisions() with Page.text.""" @@ -1013,10 +1012,9 @@ class TestItemBasePageMethods(WikidataTestCase, BasePageMethodsTestBase): """Test behavior of ItemPage methods inherited from BasePage.""" - def setUp(self) -> None: - """Setup tests.""" + def setup_page(self) -> None: + """Setup test page.""" self._page = ItemPage(self.get_repo(), 'Q60') - super().setUp() def test_basepage_methods(self) -> None: """Test ItemPage methods inherited from superclass BasePage.""" @@ -1033,10 +1031,9 @@ class TestPageMethodsWithItemTitle(WikidataTestCase, BasePageMethodsTestBase): """Test behavior of Page methods for wikibase item.""" - def setUp(self) -> None: + def setup_page(self) -> None: """Setup tests.""" self._page = pywikibot.Page(self.site, 'Q60') - super().setUp() def test_basepage_methods(self) -> None: """Test Page methods inherited from superclass BasePage with Q60.""" From 12be40752f20a8eefbd85ddf7d09d2a5ae44ffb2 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 27 Jul 2025 19:18:54 +0200 Subject: [PATCH 062/279] Doc: add new tracking_param_remover.py script to documentation Change-Id: I0753ab97936d3a54b6e20fc7bbd0accad3872fbc --- docs/scripts/general.rst | 7 +++++++ docs/scripts_ref/scripts.rst | 5 +++++ scripts/CHANGELOG.rst | 7 ++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/scripts/general.rst b/docs/scripts/general.rst index fda9d1500d..9498730bce 100644 --- a/docs/scripts/general.rst +++ b/docs/scripts/general.rst @@ -22,3 +22,10 @@ pagefromfile script .. automodule:: scripts.pagefromfile :no-members: :noindex: + +tracking param remover script +============================= + +.. automodule:: scripts.tracking_param_remover + :no-members: + :noindex: diff --git a/docs/scripts_ref/scripts.rst b/docs/scripts_ref/scripts.rst index 814f3338e3..34c1db6677 100644 --- a/docs/scripts_ref/scripts.rst +++ b/docs/scripts_ref/scripts.rst @@ -244,6 +244,11 @@ touch script .. automodule:: scripts.touch +tracking param remover script +============================= + +.. automodule:: scripts.tracking_param_remover + transferbot script ================== diff --git a/scripts/CHANGELOG.rst b/scripts/CHANGELOG.rst index 4242abafcc..c48fba9452 100644 --- a/scripts/CHANGELOG.rst +++ b/scripts/CHANGELOG.rst @@ -22,7 +22,12 @@ redirect ^^^^^^^^ * Attempt an additional move to fix redirect targets (:phab:`T396473`) -* Do not fix broken redirects if the source and target namespaces differ (:phab:`T396456`) +* Do not fix broken redirects if source and target namespaces differ (:phab:`T396456`) + +tracking_param_remover +^^^^^^^^^^^^^^^^^^^^^^ + +* Script was added (:phab:`T399698`) 10.2.0 From 60b6ca039399b399c6f68370246c9ea5f3318505 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 28 Jul 2025 14:21:46 +0200 Subject: [PATCH 063/279] Update git submodules * Update scripts/i18n from branch 'master' to aaf2d0aef4cad173ab597409e80a0e7b8a0dee52 - Localisation updates from https://translatewiki.net. Change-Id: Ibea09f65d16fc97e67bbec0c1e0c14880e5dadad --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index d562677e49..aaf2d0aef4 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit d562677e495b876fd839662ee5e34cdf8c279eae +Subproject commit aaf2d0aef4cad173ab597409e80a0e7b8a0dee52 From 01d70d09478070ab28f98afcaa17456868fb6f6a Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 31 Jul 2025 14:26:33 +0200 Subject: [PATCH 064/279] Update git submodules * Update scripts/i18n from branch 'master' to 9cb2323c047cc72de28878a5b7a726b254833587 - Localisation updates from https://translatewiki.net. Change-Id: I2d157ab6ac45f0cfe0d58f020e2173bde84a23d7 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index aaf2d0aef4..9cb2323c04 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit aaf2d0aef4cad173ab597409e80a0e7b8a0dee52 +Subproject commit 9cb2323c047cc72de28878a5b7a726b254833587 From e84137d7862236f694556507501c2b2d26ef9ba5 Mon Sep 17 00:00:00 2001 From: Xqt Date: Fri, 1 Aug 2025 12:05:00 +0000 Subject: [PATCH 065/279] Update git submodules * Update scripts/i18n from branch 'master' to 024571334d2b77065c7572a2d5edc4d11f0ae2db - Revert "Localisation updates from https://translatewiki.net." This reverts commit 9cb2323c047cc72de28878a5b7a726b254833587. Reason for revert: hak sources were just moved Change-Id: Ib4ca25958ca974bcddba2e1c56610580868c2dca --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 9cb2323c04..024571334d 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 9cb2323c047cc72de28878a5b7a726b254833587 +Subproject commit 024571334d2b77065c7572a2d5edc4d11f0ae2db From 67dd751e4624335826be44ed5b8f5eb8ef7bb985 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 2 Aug 2025 16:58:22 +0200 Subject: [PATCH 066/279] [tests] Make deletionbot dry-run test site-independent by using actual mainpage Fixes a test failure on Wikidata where 'Main Page' does not exist by dynamically retrieving the site's actual main page title via get_mainpage(). This ensures that the tested page exists on all sites and avoids false negatives due to page.exists() returning False. Also updates the assertion to reflect the dynamically determined page title. Bug: T401039 Change-Id: Ia3b5c03ee29bfcd551f78764fe1f865a7e3bf92d --- tests/deletionbot_tests.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/deletionbot_tests.py b/tests/deletionbot_tests.py index 22a521a94d..f7274f591c 100755 --- a/tests/deletionbot_tests.py +++ b/tests/deletionbot_tests.py @@ -123,10 +123,11 @@ def tearDown(self) -> None: def test_dry(self) -> None: """Test dry run of bot.""" + main = self.get_mainpage().title() with empty_sites(): - delete.main('-page:Main Page', '-always', '-summary:foo') + delete.main(f'-page:{main}', '-always', '-summary:foo') self.assertEqual(self.delete_args, - ['[[Main Page]]', 'foo', False, True, True]) + [f'[[{main}]]', 'foo', False, True, True]) with empty_sites(): delete.main( '-page:FoooOoOooO', '-always', '-summary:foo', '-undelete') From 717a8961f8a80d55015dc8c267c62fc0df82ffb2 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 2 Aug 2025 17:06:24 +0200 Subject: [PATCH 067/279] tests: update pre-commit hooks Change-Id: I0556d99b8d338c8bdc4cda755fb7ac6c1a635196 --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f7800b8a5c..a9d2d3f483 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.5 + rev: v0.12.7 hooks: - id: ruff-check alias: ruff @@ -113,7 +113,7 @@ repos: - flake8-tuple>=0.4.1 - pep8-naming>=0.15.1 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.17.0 + rev: v1.17.1 hooks: - id: mypy args: From fbae18ab138dd68b1fc4a01baebd889c989fbfdf Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 2 Aug 2025 17:17:35 +0200 Subject: [PATCH 068/279] tests: Print public IP of runner on failure Change-Id: I503c6f240d3c87b5d282765915575aca63fa7178 --- .github/workflows/pywikibot-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 8489ebd232..f359b03d80 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -147,4 +147,7 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Check on failure if: steps.ci_test.outcome == 'failure' - run: exit 1 + run: | + # Print public IP of runner + python -c "import urllib.request; print('Public IP:', urllib.request.urlopen('https://api.ipify.org').read().decode('utf-8'))" + exit 1 From ca038d5336b7c19dcfe316acb5e2e8dc4d9e58fc Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 2 Aug 2025 19:00:15 +0200 Subject: [PATCH 069/279] Tests: create_isbn_edition is not collected for script tests Therefore remove additional settings. Change-Id: Icf6c8b4ca9b512f84864b1318b7c769864c24b51 --- tests/script_tests.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/script_tests.py b/tests/script_tests.py index ecf871ec17..77567a30da 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -75,7 +75,6 @@ def list_scripts(path: str, exclude: str = '') -> list[str]: 'create_isbn_edition') script_input = { - 'create_isbn_edition': '\n', 'category_redirect': 'q\nn\n', 'interwiki': 'Test page that should not exist\n', 'misspelling': 'q\n', @@ -98,7 +97,6 @@ def list_scripts(path: str, exclude: str = '') -> list[str]: 'checkimages', 'clean_sandbox', 'commons_information', - 'create_isbn_edition', 'delinker', 'login', 'misspelling', @@ -420,7 +418,6 @@ class TestScriptGenerator(DefaultSiteTestCase, PwbTestCase, 'claimit', 'clean_sandbox', 'commonscat', - 'create_isbn_edition', 'data_ingestion', 'delinker', 'djvutext', From 328e0cdb4c8df7c4bf2f66e39b7733098f5c54b3 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 2 Aug 2025 19:48:09 +0200 Subject: [PATCH 070/279] Tests: skip test if IP is blocked This is a temporary solution until T399485 is solved. Bug: T399367 Change-Id: I05889086b043e19ca24842d570f1a10a33cca04c --- pywikibot/data/api/_requests.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/pywikibot/data/api/_requests.py b/pywikibot/data/api/_requests.py index 345b390a4f..248f5e2b54 100644 --- a/pywikibot/data/api/_requests.py +++ b/pywikibot/data/api/_requests.py @@ -44,6 +44,11 @@ __all__ = ('CachedRequest', 'Request', 'encode_url') +TEST_RUNNING = os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1' + +if TEST_RUNNING: + import unittest + # Actions that imply database updates on the server, used for various # things like throttling or skipping actions when we're in simulation # mode @@ -753,6 +758,13 @@ def _json_loads(self, response) -> dict | None: The text message is: {text} """ + if TEST_RUNNING: + if response.status_code == 402 \ + and 'Requests from your IP have been blocked' in text: + raise unittest.SkipTest(msg) # T399367 + + from tests import unittest_print + unittest_print(msg) # Do not retry for AutoFamily but raise a SiteDefinitionError # Note: family.AutoFamily is a function to create that class @@ -993,8 +1005,6 @@ def submit(self) -> dict: :return: a dict containing data retrieved from api.php """ - test_running = os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1' - self._add_defaults() use_get = self._use_get() retries = 0 @@ -1139,7 +1149,7 @@ def submit(self) -> dict: param_repr = str(self._params) msg = (f'API Error: query=\n{pprint.pformat(param_repr)}\n' f' response=\n{result}') - if test_running: + if TEST_RUNNING: from tests import unittest_print unittest_print(msg) else: @@ -1150,8 +1160,7 @@ def submit(self) -> dict: raise RuntimeError(result) msg = 'Maximum retries attempted due to maxlag without success.' - if test_running: - import unittest + if TEST_RUNNING: raise unittest.SkipTest(msg) raise MaxlagTimeoutError(msg) From 7ce35e647db72e99a32a459323ecc63a78dcf945 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 2 Aug 2025 21:33:40 +0200 Subject: [PATCH 071/279] Tests: skip test if IP is blocked Fix HTTP response. Bug: T399367 Change-Id: I27fd16da1da8003706d510abe1e70119dc84737f --- pywikibot/data/api/_requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pywikibot/data/api/_requests.py b/pywikibot/data/api/_requests.py index 248f5e2b54..0186acce15 100644 --- a/pywikibot/data/api/_requests.py +++ b/pywikibot/data/api/_requests.py @@ -759,7 +759,7 @@ def _json_loads(self, response) -> dict | None: {text} """ if TEST_RUNNING: - if response.status_code == 402 \ + if response.status_code == 403 \ and 'Requests from your IP have been blocked' in text: raise unittest.SkipTest(msg) # T399367 From 2905421724e1752670a5014034148fb3588e8e60 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 3 Aug 2025 10:43:34 +0200 Subject: [PATCH 072/279] Revert: Proceed Login CI tests wit all other actions are completed This never works if jobs are restarted because it waits for itself but the previous login_tests-ci.yml has never started and was marked as failed. Also remove printing the IP. Change-Id: I891a5a9574a47576ef6c5baac9e4370f92d4e350 --- .github/workflows/login_tests-ci.yml | 11 ----------- .github/workflows/oauth_tests-ci.yml | 3 --- .github/workflows/pywikibot-ci.yml | 2 -- 3 files changed, 16 deletions(-) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index 7f1d29e8f3..b69dd85576 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -14,19 +14,8 @@ env: PYWIKIBOT_USERNAME: Pywikibot-test jobs: - wait_for_all: - runs-on: ubuntu-latest - steps: - - name: Wait for all workflows to complete - uses: kachick/wait-other-jobs@v3.8.1 - with: - warmup-delay: PT1M - minimum-interval: PT5M - - name: Proceed with tests - run: echo "All workflows have completed. Proceeding with Login CI tests." run_tests: runs-on: ${{ matrix.os || 'ubuntu-latest' }} - needs: wait_for_all timeout-minutes: 30 strategy: fail-fast: false diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 91725ccfa8..0aa7e33c7b 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -79,9 +79,6 @@ jobs: pip install mwoauth pip install packaging pip install requests - - name: Print public IP of runner - run: | - python -c "import urllib.request; print('Public IP:', urllib.request.urlopen('https://api.ipify.org').read().decode('utf-8'))" - name: Generate family files if: ${{ matrix.family == 'wpbeta' }} run: | diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index f359b03d80..114fb7b98c 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -148,6 +148,4 @@ jobs: - name: Check on failure if: steps.ci_test.outcome == 'failure' run: | - # Print public IP of runner - python -c "import urllib.request; print('Public IP:', urllib.request.urlopen('https://api.ipify.org').read().decode('utf-8'))" exit 1 From e31834253f9cd68ee2a369469f8a04e0cdb0c865 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 3 Aug 2025 12:29:13 +0200 Subject: [PATCH 073/279] [10.3] Publish Pywikibot 10.3 Change-Id: I427c5e98d5966fe4bffdc443f1aa205eb9444768 --- ROADMAP.rst | 4 +++- pywikibot/__metadata__.py | 2 +- scripts/CHANGELOG.rst | 8 +++++++- scripts/pyproject.toml | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index 7885f2fab5..890987b41d 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,7 +1,9 @@ Current Release Changes ======================= -* Refactor the :class:`throttle.Trottle` class (:phab:`T289318`) +* :attr:`Site.articlepath` may raise a ValueError + instead of AttributeError if ``$1`` placeholder is missing from API +* Refactor the :class:`throttle.Throttle` class (:phab:`T289318`) * L10N-Updates: add language aliases for ``gsw``, ``sgs``, ``vro``, ``rup`` and ``lzh`` to :class:`family.WikimediaFamily` family class (:phab:`T399411`, :phab:`T399438`, :phab:`T399444`, :phab:`T399693`, :phab:`T399697` ) diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index 4e9889b3da..7c4c17a068 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.3.0.dev0' +__version__ = '10.3.0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' diff --git a/scripts/CHANGELOG.rst b/scripts/CHANGELOG.rst index c48fba9452..3f76e1a31f 100644 --- a/scripts/CHANGELOG.rst +++ b/scripts/CHANGELOG.rst @@ -9,8 +9,14 @@ Scripts Changelog archivebot ^^^^^^^^^^ +* Use {{talkarchive}} template by default (:phab:`T400543`) * Use Wikidata items for archive header templates (:phab:`T396399`) +create_isbn_edition +^^^^^^^^^^^^^^^^^^^ + +* This script will be removed from repository in Pywikibot 11 + interwiki ^^^^^^^^^ @@ -27,7 +33,7 @@ redirect tracking_param_remover ^^^^^^^^^^^^^^^^^^^^^^ -* Script was added (:phab:`T399698`) +* Script for removing tracking URL parameters was added (:phab:`T399698`) 10.2.0 diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index f2d70b88d1..f77eb40026 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -19,7 +19,7 @@ description = "Pywikibot Scripts Collection" readme = "scripts/README.rst" requires-python = ">=3.8.0" dependencies = [ - "pywikibot >= 10.0.0", + "pywikibot >= 10.2.0", "isbnlib", "langdetect", "mwparserfromhell", From 50136ca57e789d06bb7d132fd0635aa58bc4f68c Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 3 Aug 2025 13:36:18 +0200 Subject: [PATCH 074/279] [10.4] Prepare next release Change-Id: I3f13a6cf277daceb625b2c747d89f25ae5bf0041 --- HISTORY.rst | 27 +++++++++++++++++++++++++++ ROADMAP.rst | 22 +--------------------- pywikibot/__metadata__.py | 2 +- scripts/__init__.py | 2 +- scripts/pyproject.toml | 2 +- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index f58f9ec0a9..5ae6d5f10f 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,33 @@ Release History =============== +10.3.0 +------ +*03 August 2025* + +* :attr:`Site.articlepath` may raise a ValueError + instead of AttributeError if ``$1`` placeholder is missing from API +* Refactor the :class:`throttle.Throttle` class (:phab:`T289318`) +* L10N-Updates: add language aliases for ``gsw``, ``sgs``, ``vro``, ``rup`` and ``lzh`` + to :class:`family.WikimediaFamily` family class + (:phab:`T399411`, :phab:`T399438`, :phab:`T399444`, :phab:`T399693`, :phab:`T399697` ) +* Refactor HTML removal logic in :func:`textlib.removeHTMLParts` using :class:`textlib.GetDataHTML` + parser; *removetags* parameter was introduced to remove specified tag blocks (:phab:`T399378`) +* Refactor :class:`echo.Notification` and fix :meth:`mark_as_read()` + method (:phab:`T398770`) +* Update beta domains in family files from beta.wmflabs.org to beta.wmcloud.org (:phab:`T289318`) +* ``textlib.to_latin_digits()`` was renamed to :func:`textlib.to_ascii_digits` (:phab:`T398146#10958283`), + ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` was renamed to ``NON_ASCII_DIGITS`` +* Add -cookies option to the :mod:`login` script to log in with cookies + files only +* Create a Site using the :func:`pywikibot.Site` constructor with a given url even if the URL, even + if it ends with a slash (:phab:`T396592`) +* Remove hard-coded error messages from :meth:`login.LoginManager.login` and use API response instead +* Add additional information to :meth:`Site.login()` + error message (:phab:`T395670`) +* i18n updates + + 10.2.0 ------ *14 June 2025* diff --git a/ROADMAP.rst b/ROADMAP.rst index 890987b41d..6ed218c0d5 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,27 +1,7 @@ Current Release Changes ======================= -* :attr:`Site.articlepath` may raise a ValueError - instead of AttributeError if ``$1`` placeholder is missing from API -* Refactor the :class:`throttle.Throttle` class (:phab:`T289318`) -* L10N-Updates: add language aliases for ``gsw``, ``sgs``, ``vro``, ``rup`` and ``lzh`` - to :class:`family.WikimediaFamily` family class - (:phab:`T399411`, :phab:`T399438`, :phab:`T399444`, :phab:`T399693`, :phab:`T399697` ) -* Refactor HTML removal logic in :func:`textlib.removeHTMLParts` using :class:`textlib.GetDataHTML` - parser; *removetags* parameter was introduced to remove specified tag blocks (:phab:`T399378`) -* Refactor :class:`echo.Notification` and fix :meth:`mark_as_read()` - method (:phab:`T398770`) -* Update beta domains in family files from beta.wmflabs.org to beta.wmcloud.org (:phab:`T289318`) -* ``textlib.to_latin_digits()`` was renamed to :func:`textlib.to_ascii_digits` (:phab:`T398146#10958283`), - ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` was renamed to ``NON_ASCII_DIGITS`` -* Add -cookies option to the :mod:`login` script to log in with cookies - files only -* Create a Site using the :func:`pywikibot.Site` constructor with a given url even if the URL, even - if it ends with a slash (:phab:`T396592`) -* Remove hard-coded error messages from :meth:`login.LoginManager.login` and use API response instead -* Add additional information to :meth:`Site.login()` - error message (:phab:`T395670`) -* i18n updates +* (no changes yet) Deprecations ============ diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index 7c4c17a068..36b5043e4b 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.3.0' +__version__ = '10.4.0.dev0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' diff --git a/scripts/__init__.py b/scripts/__init__.py index 44bc808000..e42926ff70 100644 --- a/scripts/__init__.py +++ b/scripts/__init__.py @@ -34,7 +34,7 @@ from pathlib import Path -__version__ = '10.3.0' +__version__ = '10.4.0' #: defines the entry point for pywikibot-scripts package base_dir = Path(__file__).parent diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index f77eb40026..ea677d36e7 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -7,7 +7,7 @@ package-dir = {"pywikibot_scripts" = "scripts"} [project] name = "pywikibot-scripts" -version = "10.3.0" +version = "10.4.0" authors = [ {name = "xqt", email = "info@gno.de"}, From d82855655a541ec920ae56644b40ed052103fd7d Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 27 Jul 2025 13:57:13 +0200 Subject: [PATCH 075/279] IMPR: Add `expiry` parameter to Page.watch() and Site.watch() This commit enhances the Page.watch() method by adding support for the`expiry` parameter, allowing temporary watchlist entries using relative or absolute expiry times. The parameter is passed through to APISite.watch(). Additionally, positional arguments for these methods are deprecated starting with version 10.4.0. Calls must now use keyword arguments only. A warning is issued if `expiry` is passed together with `unwatch=True`, since the MediaWiki API ignores `expiry` in that case. Includes: - Updated docstring with version and usage notes. - deprecate positional arguments - Type annotations and Literal support for expiry. - Tests for watch/unwatch behavior and expiry. - Warning check for ignored expiry with unwatch. Bug: T330839 Change-Id: Ic81919eb5c5d866da6a6ca5cddec1bd5a07d01dd --- pywikibot/page/_basepage.py | 35 +++++++++++++++++++++++++++++++---- pywikibot/site/_apisite.py | 36 +++++++++++++++++++++++++++++++++--- tests/page_tests.py | 16 +++++++++++++--- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/pywikibot/page/_basepage.py b/pywikibot/page/_basepage.py index 39bc1a07fa..6e3adfbebe 100644 --- a/pywikibot/page/_basepage.py +++ b/pywikibot/page/_basepage.py @@ -1458,13 +1458,40 @@ def put(self, newtext: str, force=force, asynchronous=asynchronous, callback=callback, **kwargs) - def watch(self, unwatch: bool = False) -> bool: - """Add or remove this page to/from bot account's watchlist. + @deprecate_positionals(since='10.4.0') + def watch( + self, *, + unwatch: bool = False, + expiry: Timestamp | str | Literal[ + 'infinite', 'indefinite', 'infinity', 'never'] | None = None + ) -> bool: + """Add or remove this page from the bot account's watchlist. + + .. versionchanged:: 10.4.0 + Added the *expiry* parameter to specify watch expiry time. + Positional parameters are deprecated; all parameters must be + passed as keyword arguments. - :param unwatch: True to unwatch, False (default) to watch. + .. seealso:: + - :meth:`Site.watch()` + - :meth:`Site.watched_pages() + ` + - :api:`Watch` + + :param unwatch: If True, the page will be from the watchlist. + :param expiry: Expiry timestamp to apply to the watch. Passing + None or omitting this parameter leaves any existing expiry + unchanged. Expiry values may be relative (e.g. ``5 months`` + or ``2 weeks``) or absolute (e.g. ``2014-09-18T12:34:56Z``). + For no expiry, use ``infinite``, ``indefinite``, ``infinity`` + or `never`. For absolute timestamps the :class:`Timestamp` + class can be used. :return: True if successful, False otherwise. + :raises APIError: badexpiry: Invalid value for expiry parameter + :raises KeyError: 'watch' isn't in API response + :raises TypeError: unexpected keyword argument """ - return self.site.watch(self, unwatch) + return self.site.watch(self, unwatch=unwatch, expiry=expiry) def clear_cache(self) -> None: """Clear the cached attributes of the page.""" diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py index 9ee6fb4758..29ca5801f9 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -14,7 +14,7 @@ from collections import OrderedDict, defaultdict from contextlib import suppress from textwrap import fill -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar from warnings import warn import pywikibot @@ -74,6 +74,7 @@ MediaWikiVersion, cached, deprecate_arg, + deprecate_positionals, deprecated, issue_deprecation_warning, merge_unique_dicts, @@ -2917,27 +2918,56 @@ def unblockuser( return req.submit() @need_right('editmywatchlist') + @deprecate_positionals(since='10.4.0') def watch( self, pages: BasePage | str | list[BasePage | str], - unwatch: bool = False + *, + unwatch: bool = False, + expiry: pywikibot.Timestamp | str | Literal[ + 'infinite', 'indefinite', 'infinity', 'never'] | None = None ) -> bool: """Add or remove pages from watchlist. - .. seealso:: :api:`Watch` + .. versionchanged:: 10.4.0 + Added the *expiry* parameter to specify watch expiry time. + Passing *unwatch* as a positional parameter is deprecated; + it must be passed as keyword argument. + + .. seealso:: + - :api:`Watch` + - :meth:`BasePage.watch` + - :meth:`Site.watched_pages() + ` :param pages: A single page or a sequence of pages. :param unwatch: If True, remove pages from watchlist; if False add them (default). + :param expiry: Expiry timestamp to apply to the watch. Passing + None or omitting this parameter leaves any existing expiry + unchanged. Expiry values may be relative (e.g. ``5 months`` + or ``2 weeks``) or absolute (e.g. ``2014-09-18T12:34:56Z``). + For no expiry, use ``infinite``, ``indefinite``, ``infinity`` + or `never`. For absolute timestamps the :class:`Timestamp` + class can be used. :return: True if API returned expected response; False otherwise + :raises APIError: badexpiry: Invalid value for expiry parameter :raises KeyError: 'watch' isn't in API response + :raises TypeError: unexpected keyword argument """ parameters = { 'action': 'watch', 'titles': pages, 'token': self.tokens['watch'], 'unwatch': unwatch, + 'expiry': expiry or None, } + if not unwatch: + parameters['expiry'] = expiry or None + elif expiry: + msg = (f'\nexpiry parameter ({expiry!r}) is ignored when ' + f"unwatch=True.\nPlease omit 'expiry' when unwatching.") + warn(msg, category=UserWarning, stacklevel=2) req = self.simple_request(**parameters) results = req.submit() unwatch_s = 'unwatched' if unwatch else 'watched' diff --git a/tests/page_tests.py b/tests/page_tests.py index 5c00543ce6..813fb3bb68 100755 --- a/tests/page_tests.py +++ b/tests/page_tests.py @@ -9,6 +9,7 @@ import pickle import re +import time from contextlib import suppress from datetime import timedelta from unittest import mock @@ -1082,13 +1083,22 @@ def test_watch(self) -> None: # Note: this test uses the userpage, so that it is unwatched and # therefore is not listed by script_tests test_watchlist_simulate. + userpage = self.get_userpage() + # watched_pages parameters + wp_params = {'force': True, 'with_talkpage': False} rv = userpage.watch() - self.assertIsInstance(rv, bool) self.assertTrue(rv) - rv = userpage.watch(unwatch=True) - self.assertIsInstance(rv, bool) + self.assertIn(userpage, userpage.site.watched_pages(**wp_params)) + with self.assertWarnsRegex(UserWarning, + r"expiry parameter \('.+'\) is ignored"): + rv = userpage.watch(unwatch=True, expiry='indefinite') + self.assertTrue(rv) + rv = userpage.watch(expiry='5 seconds') self.assertTrue(rv) + self.assertIn(userpage, userpage.site.watched_pages(**wp_params)) + time.sleep(10) + self.assertNotIn(userpage, userpage.site.watched_pages(**wp_params)) class TestPageDelete(TestCase): From dadba79a985f20294b75e324fdf2585be05171e3 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 27 Jul 2025 18:36:35 +0200 Subject: [PATCH 076/279] IMPR: Clarify -localonly option behavior and help text - Improve the runtime check for -localonly to clearly compare page.site against the default site. - Adjust the error message to be more informative and specific. - Reword the help text for -localonly to accurately reflect its effect: only process pages from the default site, skipping others in the same family. - Sort settings container Bug: T57257 Change-Id: Ibc213fbd20f0441fe42f9ad0ba794f4bc12cf2bf --- scripts/interwiki.py | 77 ++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/scripts/interwiki.py b/scripts/interwiki.py index 473daac597..8be5009653 100755 --- a/scripts/interwiki.py +++ b/scripts/interwiki.py @@ -274,8 +274,8 @@ for multiple languages, and specify on which sites the bot should modify pages: --localonly Only work on the local wiki, not on other wikis in the - family I have a login at. +-localonly Process only pages from the default site; ignore pages + from other family members. -limittwo Only update two pages - one in the local wiki (if logged-in) and one in the top available one. For example, @@ -341,6 +341,10 @@ To run the script on all pages on a language, run it with option ``-start:!``, and if it takes so long that you have to break it off, use ``-continue`` next time. + +.. versionchanged:: 10.4 + The ``-localonly`` option now restricts page processing to the + default site only, instead of the origin page. """ # # (C) Pywikibot team, 2003-2025 @@ -443,46 +447,46 @@ class InterwikiBotConfig: """Container class for interwikibot's settings.""" + always = False + askhints = False + asynchronous = False + auto = True autonomous = False + cleanup = False confirm = False - always = False - select = False + followinterwiki = True followredirect = True - initialredirect = False force = False - cleanup = False - remove = [] - maxquerysize = 50 - same = False - skip = set() - skipauto = False - untranslated = False - untranslatedonly = False - auto = True - neverlink = [] - showtextlink = 0 - showtextlinkadd = 300 - localonly = False - limittwo = False - strictlimittwo = False - needlimit = 0 - ignore = [] - parenthesesonly = False - rememberno = False - followinterwiki = True - minsubjects = config.interwiki_min_subjects - nobackonly = False - askhints = False hintnobracket = False hints = [] hintsareright = False + ignore = [] + initialredirect = False + limittwo = False + localonly = False lacklanguage = None + maxquerysize = 50 minlinks = 0 + minsubjects = config.interwiki_min_subjects + needlimit = 0 + neverlink = [] + nobackonly = False + parenthesesonly = False quiet = False + rememberno = False + remove = [] + repository = False restore_all = False - asynchronous = False + same = False + select = False + showtextlink = 0 + showtextlinkadd = 300 + skip = set() + skipauto = False + strictlimittwo = False summary = '' - repository = False + untranslated = False + untranslatedonly = False def note(self, text: str) -> None: """Output a notification message with. @@ -672,6 +676,8 @@ def __init__(self, origin=None, hints=None, conf=None) -> None: self.hintsAsked = False self.forcedStop = False self.workonme = True + # default site for -localonly option + self.site = pywikibot.Site() def getFoundDisambig(self, site): """Return the first disambiguation found. @@ -1541,10 +1547,10 @@ def process_unlimited(self, new, updated) -> None: updated.append(site) def _fetch_text(self, page: pywikibot.Page) -> str: - """Validate page and load it's content for editing. + """Validate page and load its content for editing. This includes checking for: - - `-localonly` flag and whether the page is the origin + - `-localonly` flag and whether the page is on default site - Section-only pages (pages with `#section`) - Non-existent pages - Empty pages @@ -1553,9 +1559,10 @@ def _fetch_text(self, page: pywikibot.Page) -> str: :return: The text content of the page if it passes all checks. :raises SaveError: If the page is not eligible for editing. """ - # In this case only continue on the Page we started with - if self.conf.localonly and page != self.origin: - raise SaveError('-localonly and page != origin') + # In this case only continue on the Page if on default site + if self.conf.localonly and page.site != self.site: + raise SaveError(f'-localonly: {page} is on site {page.site}; ' + f'only {self.site} is accepted with this option.') if page.section(): # This is not a page, but a subpage. Do not edit it. From 49d7916987d2e588481284ec239ccb51ae082138 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 3 Aug 2025 14:02:51 +0200 Subject: [PATCH 077/279] Proceed Login CI tests when all other actions are completed" This reverts commit 2905421724e1752670a5014034148fb3588e8e60. Change-Id: I9d15ecb25a0da96e3417c74def2c6f04cb1f25f7 --- .github/workflows/login_tests-ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index b69dd85576..a8e35f1dcc 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -14,8 +14,22 @@ env: PYWIKIBOT_USERNAME: Pywikibot-test jobs: + wait_for_all: + name: Wait for other workflows to finish + runs-on: ubuntu-latest + steps: + - name: Wait for all workflows to complete excluding this one + uses: kachick/wait-other-jobs@v3.8.1 + with: + skip_same_workflow: true + warmup-delay: PT1M + minimum-interval: PT5M + fail-on-error: false + run_tests: + name: Run Login/Logout Tests runs-on: ${{ matrix.os || 'ubuntu-latest' }} + needs: wait_for_all timeout-minutes: 30 strategy: fail-fast: false From 9bf3b28bb683c5698bcc5e3c473d8e638714653e Mon Sep 17 00:00:00 2001 From: Strainu Date: Mon, 28 Jul 2025 23:46:33 +0300 Subject: [PATCH 078/279] get_best_claim: Move implementation to ItemPage Since this is a Wikibase-specific function, move the implementation to ItemPage and keep the Page version as a wrapper. Since we're touching the code, also add a test for the new implementation. Bug: T400610 Change-Id: I2fddb91a130c1e76fbb8f93b08c2b50eaf9da41d Signed-off-by: Strainu --- pywikibot/page/_page.py | 33 ++++++++++----------------------- pywikibot/page/_wikibase.py | 29 +++++++++++++++++++++++++++++ tests/wikibase_tests.py | 13 +++++++++++++ 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/pywikibot/page/_page.py b/pywikibot/page/_page.py index 1b30fbaa90..18eb1e01cd 100644 --- a/pywikibot/page/_page.py +++ b/pywikibot/page/_page.py @@ -9,7 +9,7 @@ itself, including its contents. """ # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -197,33 +197,20 @@ def get_best_claim(self, prop: str): :raises UnknownExtensionError: site has no Wikibase extension """ - def find_best_claim(claims): - """Find the first best ranked claim.""" - index = None - for i, claim in enumerate(claims): - if claim.rank == 'preferred': - return claim - if index is None and claim.rank == 'normal': - index = i - if index is None: - index = 0 - return claims[index] - - if not self.site.has_data_repository: - raise UnknownExtensionError( - f'Wikibase is not implemented for {self.site}.') - - def get_item_page(func, *args): + def get_item_page(page): + if not page.site.has_data_repository: + raise UnknownExtensionError( + f'Wikibase is not implemented for {page.site}.') try: - item_p = func(*args) + item_p = page.data_item() item_p.get() return item_p except NoPageError: return None except IsRedirectPageError: - return get_item_page(item_p.getRedirectTarget) + return get_item_page(item_p.getRedirectTarget()) - item_page = get_item_page(pywikibot.ItemPage.fromPage, self) - if item_page and prop in item_page.claims: - return find_best_claim(item_page.claims[prop]) + item_page = get_item_page(page=self) + if item_page: + return item_page.get_best_claim(prop) return None diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py index 5bf8dbd44b..bc2e5c2daf 100644 --- a/pywikibot/page/_wikibase.py +++ b/pywikibot/page/_wikibase.py @@ -1341,6 +1341,35 @@ def isRedirectPage(self): return self._isredir return super().isRedirectPage() + def get_best_claim(self, prop: str): + """Return the first best Claim for this page. + + Return the first 'preferred' ranked Claim specified by Wikibase + property or the first 'normal' one otherwise. + + :param prop: property id, "P###" + :return: Claim object given by Wikibase property number + for this page object. + :rtype: pywikibot.Claim or None + + :raises UnknownExtensionError: site has no Wikibase extension + """ + def find_best_claim(claims): + """Find the first best ranked claim.""" + index = None + for i, claim in enumerate(claims): + if claim.rank == 'preferred': + return claim + if index is None and claim.rank == 'normal': + index = i + if index is None: + index = 0 + return claims[index] + + if prop in self.claims: + return find_best_claim(self.claims[prop]) + return None + class Property: diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py index 4d1d77fa4d..47a736d20d 100755 --- a/tests/wikibase_tests.py +++ b/tests/wikibase_tests.py @@ -1497,6 +1497,19 @@ def test_json_diff(self) -> None: self.assertEqual(diff, expected) +class TestHighLevelApi(WikidataTestCase): + + """Test high-level API for Wikidata.""" + + def test_get_best_claim(self) -> None: + """Test getting the best claim for a property.""" + wikidata = self.get_repo() + item = pywikibot.ItemPage(wikidata, 'Q90') + item.get() + self.assertEqual(item.get_best_claim('P17').getTarget(), + pywikibot.ItemPage(wikidata, 'Q142')) + + if __name__ == '__main__': with suppress(SystemExit): unittest.main() From d67a8958c0d073c9dea877b01b23309be238086f Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 3 Aug 2025 16:24:34 +0200 Subject: [PATCH 079/279] Doc: Update documentation for get_best_claim methods - add version strings - add references notes - update ROADMAP.rst Change-Id: I91d37d637e621cb817174a73eb5bbf44dc7f6a67 --- ROADMAP.rst | 5 ++++- pywikibot/page/_page.py | 8 +++++--- pywikibot/page/_wikibase.py | 10 +++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index 6ed218c0d5..82d9331e0a 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,7 +1,10 @@ Current Release Changes ======================= -* (no changes yet) +* Implement :meth:`pywikibot.ItemPage.get_best_claim` (:phab:`T400610`) +* Add *expiry* parameter to :meth:`BasePage.watch()` and + :meth:`Site.watch()` (:phab:`T330839`) + Deprecations ============ diff --git a/pywikibot/page/_page.py b/pywikibot/page/_page.py index 18eb1e01cd..8a5a5a7d90 100644 --- a/pywikibot/page/_page.py +++ b/pywikibot/page/_page.py @@ -182,7 +182,7 @@ def set_redirect_target( if save: self.save(**kwargs) - def get_best_claim(self, prop: str): + def get_best_claim(self, prop: str) -> pywikibot.Claim | None: """Return the first best Claim for this page. Return the first 'preferred' ranked Claim specified by Wikibase @@ -190,10 +190,12 @@ def get_best_claim(self, prop: str): .. versionadded:: 3.0 - :param prop: property id, "P###" + .. seealso:: :meth:`pywikibot.ItemPage.get_best_claim` + + :param prop: Wikibase property ID, must be of the form ``P`` + followed by one or more digits (e.g. ``P31``). :return: Claim object given by Wikibase property number for this page object. - :rtype: pywikibot.Claim or None :raises UnknownExtensionError: site has no Wikibase extension """ diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py index bc2e5c2daf..3c1df0abc0 100644 --- a/pywikibot/page/_wikibase.py +++ b/pywikibot/page/_wikibase.py @@ -1341,16 +1341,20 @@ def isRedirectPage(self): return self._isredir return super().isRedirectPage() - def get_best_claim(self, prop: str): + def get_best_claim(self, prop: str) -> pywikibot.Claim | None: """Return the first best Claim for this page. Return the first 'preferred' ranked Claim specified by Wikibase property or the first 'normal' one otherwise. - :param prop: property id, "P###" + .. versionadded:: 10.4 + + .. seealso:: :meth:`pywikibot.Page.get_best_claim` + + :param prop: Wikibase property ID, must be of the form ``P`` + followed by one or more digits (e.g. ``P31``). :return: Claim object given by Wikibase property number for this page object. - :rtype: pywikibot.Claim or None :raises UnknownExtensionError: site has no Wikibase extension """ From d8e903c8410e4c5a580fe45871e60bb8e51d494e Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 3 Aug 2025 16:54:28 +0200 Subject: [PATCH 080/279] pyproject: add Python versions for pypi/github badge Change-Id: Ib0b0d3feaca9d02fee78f047c31d6dd3451f74e2 --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index fbfdc1aa84..ad901e9ba6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,14 @@ classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Wiki", From db30bd95129e5465f888018c0ea6232068358d05 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 3 Aug 2025 17:47:25 +0200 Subject: [PATCH 081/279] Cleanup: Update setup.py - remove unsupported test_suite and tests_require - use tomllib with Python 3.11 - fix print statement for warning Bug: T396356 Change-Id: I8e6030f3036e754ff17768b62e160e7592cbffc1 --- setup.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/setup.py b/setup.py index 2ad8803f2a..80d62fa993 100755 --- a/setup.py +++ b/setup.py @@ -78,17 +78,6 @@ 'requests>=2.31.0', ] -# ------- setup tests_require ------- # -test_deps = [] - -# Add all dependencies as test dependencies, -# so all scripts can be compiled for script_tests, etc. -if 'PYSETUP_TEST_EXTRAS' in os.environ: # pragma: no cover - test_deps += [i for v in extra_deps.values() for i in v] - -# These extra dependencies are needed other unittest fails to load tests. -test_deps += extra_deps['eventstreams'] - class _DottedDict(dict): __getattr__ = dict.__getitem__ @@ -106,6 +95,12 @@ def read_project() -> str: .. versionadded:: 9.0 """ + if sys.version_info >= (3, 11): + import tomllib + with open(path / 'pyproject.toml', 'rb') as f: + data = tomllib.load(f) + return data['project']['name'] + toml = [] with open(path / 'pyproject.toml') as f: for line in f: @@ -174,7 +169,7 @@ def get_validated_version(name: str) -> str: # pragma: no cover if warning: print(__doc__) - print('\n\n{warning}') + print(f'\n\n{warning}') sys.exit('\nBuild of distribution package canceled.') return version @@ -230,8 +225,6 @@ def main() -> None: # pragma: no cover include_package_data=True, install_requires=dependencies, extras_require=extra_deps, - test_suite='tests.collector', - tests_require=test_deps, ) From 6fe9c11aed5c2e3ebf8fed51fc121baeaad0f4db Mon Sep 17 00:00:00 2001 From: Xqt Date: Mon, 4 Aug 2025 05:40:59 +0000 Subject: [PATCH 082/279] Enable coverage UI Change-Id: I2ded0f49520935eb070d5fb2d77919ba7f8c43e5 Signed-off-by: Xqt --- .github/workflows/pywikibot-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 114fb7b98c..5943c7d1b8 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -141,6 +141,7 @@ jobs: - name: Show coverage statistics run: | coverage report + coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 env: From 1bee10355345cdc18397c766d68c48e3821198ef Mon Sep 17 00:00:00 2001 From: Xqt Date: Mon, 4 Aug 2025 12:22:23 +0000 Subject: [PATCH 083/279] Revert "Enable coverage UI" This reverts commit 6fe9c11aed5c2e3ebf8fed51fc121baeaad0f4db. Reason for revert: Test purpose. Inspect the missing uploads. Change-Id: I439c9ee91102de45ae87eb8953f9d81a449aab4c --- .github/workflows/pywikibot-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 5943c7d1b8..114fb7b98c 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -141,7 +141,6 @@ jobs: - name: Show coverage statistics run: | coverage report - coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 env: From fbdf69bc736da39965679df7f0bb3382d95b8fcc Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 4 Aug 2025 16:06:50 +0200 Subject: [PATCH 084/279] Tests: Fix watch() to return False if page missing and no expiry set - Return False when watching pages without expiry if any page is missing, reflecting that missing pages are not added to the watchlist. - Adjust test_watch() to assert this behavior. - Improve condition checking for 'missing', 'watched', and absence of 'expiry'. - Update docstring and add a note about this case. Bug: T330839 Change-Id: I0183f12879495814c31d68d1b00a96580b9a3b6c --- pywikibot/site/_apisite.py | 22 +++++++++++++++++++--- tests/page_tests.py | 8 ++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py index 29ca5801f9..34550dd392 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -2934,6 +2934,10 @@ def watch( Passing *unwatch* as a positional parameter is deprecated; it must be passed as keyword argument. + .. note:: When watching a page without *expiry*, the function + returns False if any page does not exist, because it was + not added to the watchlist. + .. seealso:: - :api:`Watch` - :meth:`BasePage.watch` @@ -2950,7 +2954,9 @@ def watch( For no expiry, use ``infinite``, ``indefinite``, ``infinity`` or `never`. For absolute timestamps the :class:`Timestamp` class can be used. - :return: True if API returned expected response; False otherwise + :return: True if API returns expected response; False otherwise. + If *unwatch* is False, *expiry* is None or specifies no + defined end date, return False if the page does not exist. :raises APIError: badexpiry: Invalid value for expiry parameter :raises KeyError: 'watch' isn't in API response :raises TypeError: unexpected keyword argument @@ -2962,16 +2968,26 @@ class can be used. 'unwatch': unwatch, 'expiry': expiry or None, } + if not unwatch: parameters['expiry'] = expiry or None elif expiry: msg = (f'\nexpiry parameter ({expiry!r}) is ignored when ' f"unwatch=True.\nPlease omit 'expiry' when unwatching.") warn(msg, category=UserWarning, stacklevel=2) + req = self.simple_request(**parameters) results = req.submit() - unwatch_s = 'unwatched' if unwatch else 'watched' - return all(unwatch_s in r for r in results['watch']) + watchtype = 'unwatched' if unwatch else 'watched' + + for r in results['watch']: + if watchtype not in r: + return False + + if 'missing' in r and 'watched' in r and 'expiry' not in r: + return False + + return True def purgepages( self, diff --git a/tests/page_tests.py b/tests/page_tests.py index 813fb3bb68..3baebeeba9 100755 --- a/tests/page_tests.py +++ b/tests/page_tests.py @@ -1088,11 +1088,15 @@ def test_watch(self) -> None: # watched_pages parameters wp_params = {'force': True, 'with_talkpage': False} rv = userpage.watch() - self.assertTrue(rv) - self.assertIn(userpage, userpage.site.watched_pages(**wp_params)) + + self.assertEqual(userpage.exists(), rv) + if rv: + self.assertIn(userpage, userpage.site.watched_pages(**wp_params)) + with self.assertWarnsRegex(UserWarning, r"expiry parameter \('.+'\) is ignored"): rv = userpage.watch(unwatch=True, expiry='indefinite') + self.assertTrue(rv) rv = userpage.watch(expiry='5 seconds') self.assertTrue(rv) From 4f6c4f3883c9b94183915405f7889a868ac99f48 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 4 Aug 2025 16:43:59 +0200 Subject: [PATCH 085/279] Update git submodules * Update scripts/i18n from branch 'master' to 4f7d48e1ce0afcfbcc2bf3c18e92294df036f509 - Localisation updates from https://translatewiki.net. Change-Id: I671241150060ee38622255c237c36ab2187dc50d --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 024571334d..4f7d48e1ce 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 024571334d2b77065c7572a2d5edc4d11f0ae2db +Subproject commit 4f7d48e1ce0afcfbcc2bf3c18e92294df036f509 From c13928ce53db4f462491204f4a1f0ba39863b83f Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 4 Aug 2025 17:27:13 +0200 Subject: [PATCH 086/279] Tests: Enable coverage measurement in subprocesses started by execute_pwb() - Detect if tests are running via PYWIKIBOT_TEST_RUNNING environment variable - Attempt to import coverage module and activate coverage run for subprocess - Prepend 'python -m coverage run' to the command when coverage is available - Ensures coverage data includes code executed in pwb.py subprocesses - Gracefully handles missing coverage module by skipping coverage injection - use coverage combine in github action Bug: T401124 Change-Id: Ib81936c68fcf7e54e9db0375fafd40f7ca45eabc --- .github/workflows/doctest.yml | 1 + .github/workflows/login_tests-ci.yml | 1 + .github/workflows/oauth_tests-ci.yml | 1 + .github/workflows/pywikibot-ci.yml | 1 + .github/workflows/sysop_write_tests-ci.yml | 1 + .github/workflows/windows_tests.yml | 1 + tests/utils.py | 6 ++++++ 7 files changed, 12 insertions(+) diff --git a/.github/workflows/doctest.yml b/.github/workflows/doctest.yml index dee414e153..6c9b8b5428 100644 --- a/.github/workflows/doctest.yml +++ b/.github/workflows/doctest.yml @@ -68,6 +68,7 @@ jobs: coverage run -m pytest pywikibot --doctest-modules --ignore-glob="*gui.py" --ignore-glob="*memento.py" - name: Show coverage statistics run: | + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index a8e35f1dcc..fe660424ef 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -124,6 +124,7 @@ jobs: coverage run -m unittest -vv tests/site_login_logout_tests.py - name: Show coverage statistics run: | + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 0aa7e33c7b..008124ba6c 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -102,6 +102,7 @@ jobs: coverage run -m unittest -vv - name: Show coverage statistics run: | + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 114fb7b98c..dea449927b 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -140,6 +140,7 @@ jobs: fi - name: Show coverage statistics run: | + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/sysop_write_tests-ci.yml b/.github/workflows/sysop_write_tests-ci.yml index a08b124a08..917ba2215d 100644 --- a/.github/workflows/sysop_write_tests-ci.yml +++ b/.github/workflows/sysop_write_tests-ci.yml @@ -62,6 +62,7 @@ jobs: coverage run -m pytest -s -r A -a "${{ matrix.attr }}" - name: Show coverage statistics run: | + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/windows_tests.yml b/.github/workflows/windows_tests.yml index 13a24057f5..579d45a22b 100644 --- a/.github/workflows/windows_tests.yml +++ b/.github/workflows/windows_tests.yml @@ -75,6 +75,7 @@ jobs: coverage run -m unittest discover -vv -p \"*_tests.py\"; - name: Show coverage statistics run: | + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/tests/utils.py b/tests/utils.py index 8f5d342dba..1c697936cf 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -530,6 +530,12 @@ def execute_pwb(args: list[str], *, else: command.append(_pwb_py) + # Test is running; activate coverage if present + if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1': + with suppress(ModuleNotFoundError): + import coverage # noqa: F401 + command = [command[0], '-m', 'coverage', 'run'] + command[1:] + return execute(command=command + args, data_in=data_in, timeout=timeout) From 615f76892043c840f7c46e3723088f8213f72a0e Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 4 Aug 2025 18:04:16 +0200 Subject: [PATCH 087/279] cleanup: preload_sites script was removed Bug: T348925 Change-Id: I114e367145c7e8f97427af87aba07e83700c1fda --- .codecov.yml | 1 - pywikibot/scripts/__init__.py | 3 ++- scripts/CHANGELOG.rst | 6 ------ 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 403b07313d..9d56b91f84 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -22,7 +22,6 @@ coverage: - Users - pywikibot/daemonize.py - pywikibot/families/__init__.py - - pywikibot/scripts/preload_sites.py - pywikibot/scripts/version.py - scripts/create_isbn_edition.py - scripts/maintenance/colors.py diff --git a/pywikibot/scripts/__init__.py b/pywikibot/scripts/__init__.py index 87676573a1..415cf12752 100644 --- a/pywikibot/scripts/__init__.py +++ b/pywikibot/scripts/__init__.py @@ -2,7 +2,8 @@ .. versionadded:: 7.0 .. versionremoved:: 9.4 - ``preload_sites`` script was removed (:phab:`T348925`). + ``preload_sites`` script, previously added in release 6.0 + (:phab:`T226157`), was removed (:phab:`T348925`). """ # # (C) Pywikibot team, 2021-2025 diff --git a/scripts/CHANGELOG.rst b/scripts/CHANGELOG.rst index 3f76e1a31f..1c93e6c7c6 100644 --- a/scripts/CHANGELOG.rst +++ b/scripts/CHANGELOG.rst @@ -1051,12 +1051,6 @@ login * update help string -maintenance -^^^^^^^^^^^ - -* Add a preload_sites.py script to preload site information - (:phab:`T226157`) - reflinks ^^^^^^^^ From daad84a34fa365fcd208697d4fe8fa812939a828 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 4 Aug 2025 18:37:29 +0200 Subject: [PATCH 088/279] Improve execute_pwb to activate coverage only for non-override runs - Activate coverage measurement by prepending `-m coverage run` only when executing the main pwb.py script (i.e., in the else branch). - Skip coverage wrapping when running inline Python code with `-c` (overrides), as coverage module does not support this usage. - Use environment variable PYWIKIBOT_TEST_RUNNING to toggle coverage activation during test runs. - Suppress ModuleNotFoundError if coverage is not installed to keep fallback clean. Also update GitHub Actions to skip coverage combine errors by adding `|| true` to the coverage combine command, preventing job failures when there are no coverage files to combine. Bug: T401124 Change-Id: Ib05eba28351107e5626260a976122d3fa076da21 --- .github/workflows/doctest.yml | 2 +- .github/workflows/login_tests-ci.yml | 2 +- .github/workflows/oauth_tests-ci.yml | 2 +- .github/workflows/pywikibot-ci.yml | 2 +- .github/workflows/sysop_write_tests-ci.yml | 2 +- .github/workflows/windows_tests.yml | 2 +- tests/utils.py | 12 ++++++------ 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/doctest.yml b/.github/workflows/doctest.yml index 6c9b8b5428..eb55cb64a2 100644 --- a/.github/workflows/doctest.yml +++ b/.github/workflows/doctest.yml @@ -68,7 +68,7 @@ jobs: coverage run -m pytest pywikibot --doctest-modules --ignore-glob="*gui.py" --ignore-glob="*memento.py" - name: Show coverage statistics run: | - coverage combine + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index fe660424ef..835ad56fc9 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -124,7 +124,7 @@ jobs: coverage run -m unittest -vv tests/site_login_logout_tests.py - name: Show coverage statistics run: | - coverage combine + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 008124ba6c..0d8228ccae 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -102,7 +102,7 @@ jobs: coverage run -m unittest -vv - name: Show coverage statistics run: | - coverage combine + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index dea449927b..46f51d8f2a 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -140,7 +140,7 @@ jobs: fi - name: Show coverage statistics run: | - coverage combine + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/sysop_write_tests-ci.yml b/.github/workflows/sysop_write_tests-ci.yml index 917ba2215d..b7a1b7df16 100644 --- a/.github/workflows/sysop_write_tests-ci.yml +++ b/.github/workflows/sysop_write_tests-ci.yml @@ -62,7 +62,7 @@ jobs: coverage run -m pytest -s -r A -a "${{ matrix.attr }}" - name: Show coverage statistics run: | - coverage combine + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/windows_tests.yml b/.github/workflows/windows_tests.yml index 579d45a22b..95c8aed240 100644 --- a/.github/workflows/windows_tests.yml +++ b/.github/workflows/windows_tests.yml @@ -75,7 +75,7 @@ jobs: coverage run -m unittest discover -vv -p \"*_tests.py\"; - name: Show coverage statistics run: | - coverage combine + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/tests/utils.py b/tests/utils.py index 1c697936cf..454f41a0d1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -528,13 +528,13 @@ def execute_pwb(args: list[str], *, command.append( f'import pwb; import pywikibot; {overrides}; pwb.main()') else: - command.append(_pwb_py) + # Test is running; activate coverage if present + if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1': + with suppress(ModuleNotFoundError): + import coverage # noqa: F401 + command.extend(['-m', 'coverage', 'run']) - # Test is running; activate coverage if present - if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1': - with suppress(ModuleNotFoundError): - import coverage # noqa: F401 - command = [command[0], '-m', 'coverage', 'run'] + command[1:] + command.append(_pwb_py) return execute(command=command + args, data_in=data_in, timeout=timeout) From 998cc1168694b48eff99247dff7fcc5c78aab179 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 4 Aug 2025 19:40:47 +0200 Subject: [PATCH 089/279] Tests: cover wrapper.run_python_file function which invokes scripts Bug: T401124 Change-Id: Id185d988069e533ef60b47eee3fcf6f96ec88bb8 --- .github/workflows/pywikibot-ci.yml | 1 + pywikibot/scripts/wrapper.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 46f51d8f2a..f64cf69dc4 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -130,6 +130,7 @@ jobs: continue-on-error: true timeout-minutes: 90 env: + COVERAGE_PROCESS_START: pyproject.toml PYWIKIBOT_TEST_NO_RC: ${{ (matrix.site == 'wikisource:zh' || matrix.test_no_rc) && 1 || 0 }} run: | python pwb.py version diff --git a/pywikibot/scripts/wrapper.py b/pywikibot/scripts/wrapper.py index d4b0081f7c..656decb70a 100755 --- a/pywikibot/scripts/wrapper.py +++ b/pywikibot/scripts/wrapper.py @@ -125,6 +125,14 @@ def run_python_file(filename: str, args: list[str], package=None) -> None: :param package: The package of the script. Used for checks. :type package: Optional[module] """ + if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1': + try: + import coverage + except ModuleNotFoundError: + pass + else: + coverage.process_startup() + # Create a module to serve as __main__ old_main_mod = sys.modules['__main__'] main_mod = types.ModuleType('__main__') From da10d36dcf9c9726e2362549f8ed799bf8d30ea7 Mon Sep 17 00:00:00 2001 From: Xqt Date: Mon, 4 Aug 2025 18:58:55 +0000 Subject: [PATCH 090/279] Revert "Tests: cover wrapper.run_python_file function which invokes scripts" This reverts commit 998cc1168694b48eff99247dff7fcc5c78aab179. Reason for revert:no improvements Change-Id: I1f1169937a594cc86483a2be79ff72d5873b4995 --- .github/workflows/pywikibot-ci.yml | 1 - pywikibot/scripts/wrapper.py | 8 -------- 2 files changed, 9 deletions(-) diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index f64cf69dc4..46f51d8f2a 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -130,7 +130,6 @@ jobs: continue-on-error: true timeout-minutes: 90 env: - COVERAGE_PROCESS_START: pyproject.toml PYWIKIBOT_TEST_NO_RC: ${{ (matrix.site == 'wikisource:zh' || matrix.test_no_rc) && 1 || 0 }} run: | python pwb.py version diff --git a/pywikibot/scripts/wrapper.py b/pywikibot/scripts/wrapper.py index 656decb70a..d4b0081f7c 100755 --- a/pywikibot/scripts/wrapper.py +++ b/pywikibot/scripts/wrapper.py @@ -125,14 +125,6 @@ def run_python_file(filename: str, args: list[str], package=None) -> None: :param package: The package of the script. Used for checks. :type package: Optional[module] """ - if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1': - try: - import coverage - except ModuleNotFoundError: - pass - else: - coverage.process_startup() - # Create a module to serve as __main__ old_main_mod = sys.modules['__main__'] main_mod = types.ModuleType('__main__') From f557fa649a52d50cbd08835203cfeef55b1c3c67 Mon Sep 17 00:00:00 2001 From: Xqt Date: Mon, 4 Aug 2025 19:00:41 +0000 Subject: [PATCH 091/279] Revert "Improve execute_pwb to activate coverage only for non-override runs" This reverts commit daad84a34fa365fcd208697d4fe8fa812939a828. Reason for revert: no improvements found Change-Id: I99f08f5619f2fdc743908cf87192734d9e65eb47 --- .github/workflows/doctest.yml | 2 +- .github/workflows/login_tests-ci.yml | 2 +- .github/workflows/oauth_tests-ci.yml | 2 +- .github/workflows/pywikibot-ci.yml | 2 +- .github/workflows/sysop_write_tests-ci.yml | 2 +- .github/workflows/windows_tests.yml | 2 +- tests/utils.py | 12 ++++++------ 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/doctest.yml b/.github/workflows/doctest.yml index eb55cb64a2..6c9b8b5428 100644 --- a/.github/workflows/doctest.yml +++ b/.github/workflows/doctest.yml @@ -68,7 +68,7 @@ jobs: coverage run -m pytest pywikibot --doctest-modules --ignore-glob="*gui.py" --ignore-glob="*memento.py" - name: Show coverage statistics run: | - coverage combine || true + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index 835ad56fc9..fe660424ef 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -124,7 +124,7 @@ jobs: coverage run -m unittest -vv tests/site_login_logout_tests.py - name: Show coverage statistics run: | - coverage combine || true + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 0d8228ccae..008124ba6c 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -102,7 +102,7 @@ jobs: coverage run -m unittest -vv - name: Show coverage statistics run: | - coverage combine || true + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 46f51d8f2a..dea449927b 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -140,7 +140,7 @@ jobs: fi - name: Show coverage statistics run: | - coverage combine || true + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/sysop_write_tests-ci.yml b/.github/workflows/sysop_write_tests-ci.yml index b7a1b7df16..917ba2215d 100644 --- a/.github/workflows/sysop_write_tests-ci.yml +++ b/.github/workflows/sysop_write_tests-ci.yml @@ -62,7 +62,7 @@ jobs: coverage run -m pytest -s -r A -a "${{ matrix.attr }}" - name: Show coverage statistics run: | - coverage combine || true + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/windows_tests.yml b/.github/workflows/windows_tests.yml index 95c8aed240..579d45a22b 100644 --- a/.github/workflows/windows_tests.yml +++ b/.github/workflows/windows_tests.yml @@ -75,7 +75,7 @@ jobs: coverage run -m unittest discover -vv -p \"*_tests.py\"; - name: Show coverage statistics run: | - coverage combine || true + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/tests/utils.py b/tests/utils.py index 454f41a0d1..1c697936cf 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -528,14 +528,14 @@ def execute_pwb(args: list[str], *, command.append( f'import pwb; import pywikibot; {overrides}; pwb.main()') else: - # Test is running; activate coverage if present - if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1': - with suppress(ModuleNotFoundError): - import coverage # noqa: F401 - command.extend(['-m', 'coverage', 'run']) - command.append(_pwb_py) + # Test is running; activate coverage if present + if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1': + with suppress(ModuleNotFoundError): + import coverage # noqa: F401 + command = [command[0], '-m', 'coverage', 'run'] + command[1:] + return execute(command=command + args, data_in=data_in, timeout=timeout) From 38cf5b841ee09c6acef3c5b26e5a6877e668c425 Mon Sep 17 00:00:00 2001 From: Xqt Date: Mon, 4 Aug 2025 19:03:03 +0000 Subject: [PATCH 092/279] Revert "Tests: Enable coverage measurement in subprocesses started by execute_pwb()" This reverts commit c13928ce53db4f462491204f4a1f0ba39863b83f. Reason for revert: no improvements Change-Id: Ib48240b5e2c47b1db6448ef98fdbf7d3fb850d1a --- .github/workflows/doctest.yml | 1 - .github/workflows/login_tests-ci.yml | 1 - .github/workflows/oauth_tests-ci.yml | 1 - .github/workflows/pywikibot-ci.yml | 1 - .github/workflows/sysop_write_tests-ci.yml | 1 - .github/workflows/windows_tests.yml | 1 - tests/utils.py | 6 ------ 7 files changed, 12 deletions(-) diff --git a/.github/workflows/doctest.yml b/.github/workflows/doctest.yml index 6c9b8b5428..dee414e153 100644 --- a/.github/workflows/doctest.yml +++ b/.github/workflows/doctest.yml @@ -68,7 +68,6 @@ jobs: coverage run -m pytest pywikibot --doctest-modules --ignore-glob="*gui.py" --ignore-glob="*memento.py" - name: Show coverage statistics run: | - coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index fe660424ef..a8e35f1dcc 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -124,7 +124,6 @@ jobs: coverage run -m unittest -vv tests/site_login_logout_tests.py - name: Show coverage statistics run: | - coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 008124ba6c..0aa7e33c7b 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -102,7 +102,6 @@ jobs: coverage run -m unittest -vv - name: Show coverage statistics run: | - coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index dea449927b..114fb7b98c 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -140,7 +140,6 @@ jobs: fi - name: Show coverage statistics run: | - coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/sysop_write_tests-ci.yml b/.github/workflows/sysop_write_tests-ci.yml index 917ba2215d..a08b124a08 100644 --- a/.github/workflows/sysop_write_tests-ci.yml +++ b/.github/workflows/sysop_write_tests-ci.yml @@ -62,7 +62,6 @@ jobs: coverage run -m pytest -s -r A -a "${{ matrix.attr }}" - name: Show coverage statistics run: | - coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/windows_tests.yml b/.github/workflows/windows_tests.yml index 579d45a22b..13a24057f5 100644 --- a/.github/workflows/windows_tests.yml +++ b/.github/workflows/windows_tests.yml @@ -75,7 +75,6 @@ jobs: coverage run -m unittest discover -vv -p \"*_tests.py\"; - name: Show coverage statistics run: | - coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/tests/utils.py b/tests/utils.py index 1c697936cf..8f5d342dba 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -530,12 +530,6 @@ def execute_pwb(args: list[str], *, else: command.append(_pwb_py) - # Test is running; activate coverage if present - if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1': - with suppress(ModuleNotFoundError): - import coverage # noqa: F401 - command = [command[0], '-m', 'coverage', 'run'] + command[1:] - return execute(command=command + args, data_in=data_in, timeout=timeout) From bfe1e5c0fb8d9e53b69facfe19bfb511c281f5f2 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 5 Aug 2025 13:06:15 +0200 Subject: [PATCH 093/279] Suppress ResourceWarning for PyPy in TestTerminalInput Also fix login_tests-ci.yml github action Bug: T401187 Change-Id: Ic5956342bf7d04d3e4a56847faa1a9e4efdd6d53 --- .github/workflows/login_tests-ci.yml | 2 +- tests/ui_tests.py | 22 +++++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index a8e35f1dcc..de09d814be 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -21,7 +21,7 @@ jobs: - name: Wait for all workflows to complete excluding this one uses: kachick/wait-other-jobs@v3.8.1 with: - skip_same_workflow: true + skip-same-workflow: true warmup-delay: PT1M minimum-interval: PT5M fail-on-error: false diff --git a/tests/ui_tests.py b/tests/ui_tests.py index dc3daa367b..a743e4a11c 100755 --- a/tests/ui_tests.py +++ b/tests/ui_tests.py @@ -10,8 +10,9 @@ import io import logging import os +import platform import unittest -from contextlib import redirect_stdout, suppress +from contextlib import nullcontext, redirect_stdout, suppress from typing import NoReturn from unittest.mock import patch @@ -26,6 +27,7 @@ VERBOSE, WARNING, ) +from pywikibot.tools import suppress_warnings from pywikibot.userinterfaces import ( terminal_interface_base, terminal_interface_unix, @@ -242,12 +244,18 @@ def testInputChoiceCapital(self) -> None: self.assertEqual(returned, 'n') def testInputChoiceNonCapital(self) -> None: - self.strin.write('n\n') - self.strin.seek(0) - returned = self._call_input_choice() - - self.assertEqual(self.strerr.getvalue(), self.input_choice_output) - self.assertEqual(returned, 'n') + if platform.python_implementation() == 'PyPy': + context = suppress_warnings(r'subprocess \d+ is still running', + ResourceWarning) + else: + context = nullcontext() + with context: + self.strin.write('n\n') + self.strin.seek(0) + returned = self._call_input_choice() + + self.assertEqual(self.strerr.getvalue(), self.input_choice_output) + self.assertEqual(returned, 'n') def testInputChoiceIncorrectAnswer(self) -> None: self.strin.write('X\nN\n') From b8120086c9fed693e270d1a76b15e54f4144fcf8 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 7 Aug 2025 14:21:28 +0200 Subject: [PATCH 094/279] Update git submodules * Update scripts/i18n from branch 'master' to 15ffccb0956e50aa081ff53124a633e5a209a704 - Localisation updates from https://translatewiki.net. Change-Id: I59e312bdcb1b661e028ddd9ae492ee0a17e37b74 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 4f7d48e1ce..15ffccb095 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 4f7d48e1ce0afcfbcc2bf3c18e92294df036f509 +Subproject commit 15ffccb0956e50aa081ff53124a633e5a209a704 From 33cf8c4d03e49f3702cf4063544177430f45c1f6 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 8 Aug 2025 09:35:13 +0200 Subject: [PATCH 095/279] Update git submodules * Update scripts/i18n from branch 'master' to 23d38bc21e6666666ce4bd9f8945a38383a9346e - Revert "Localisation updates from https://translatewiki.net." This reverts commit 15ffccb0956e50aa081ff53124a633e5a209a704. Change-Id: I43fdb407f418d5943e85aa2e3f28267b70471dbd --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 15ffccb095..23d38bc21e 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 15ffccb0956e50aa081ff53124a633e5a209a704 +Subproject commit 23d38bc21e6666666ce4bd9f8945a38383a9346e From 4485e8a0fd2c485c4970cd9b5d86c1c9c5e6f280 Mon Sep 17 00:00:00 2001 From: Alexander Vorwerk Date: Fri, 8 Aug 2025 12:29:48 +0200 Subject: [PATCH 096/279] Add support for tlwikisource Bug: T388656 Change-Id: I8cae019393c36c3624148bc5a3bb102367b79772 --- pywikibot/families/wikisource_family.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pywikibot/families/wikisource_family.py b/pywikibot/families/wikisource_family.py index d5ea0eef08..d096fb7fc0 100644 --- a/pywikibot/families/wikisource_family.py +++ b/pywikibot/families/wikisource_family.py @@ -33,7 +33,7 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'ja', 'jv', 'ka', 'kn', 'ko', 'la', 'li', 'lij', 'lt', 'mk', 'ml', 'mr', 'ms', 'mul', 'my', 'nap', 'nl', 'no', 'or', 'pa', 'pl', 'pms', 'pt', 'ro', 'ru', 'sa', 'sah', 'sk', 'sl', 'sr', 'su', 'sv', 'ta', - 'tcy', 'te', 'th', 'tr', 'uk', 'vec', 'vi', 'wa', 'yi', 'zh', + 'tcy', 'te', 'th', 'tl', 'tr', 'uk', 'vec', 'vi', 'wa', 'yi', 'zh', 'zh-min-nan', } From 6e2b2999fb69742cb74dc074b61a530bf1f88e6d Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 8 Aug 2025 15:45:45 +0200 Subject: [PATCH 097/279] Tests: update login_tests-ci.yml to skip wait cycle for itself Change-Id: Ied2fffe68b7c4aacf41f7527dee4e7a06e787a45 --- .github/workflows/login_tests-ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index de09d814be..e86a1806e8 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -22,9 +22,15 @@ jobs: uses: kachick/wait-other-jobs@v3.8.1 with: skip-same-workflow: true + skip-list: | + [ + { + "workflowFile": "login_tests-ci.yml", + "jobName": "Wait for other workflows to finish" + } + ] warmup-delay: PT1M minimum-interval: PT5M - fail-on-error: false run_tests: name: Run Login/Logout Tests From 0df3aec7db7687ef465d36b4efe696904d820baf Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 8 Aug 2025 16:48:07 +0200 Subject: [PATCH 098/279] [10.4.0] Update version after stable 10.3.1 is published Also update ROADMAP.rst, HISTORY.rst and CHANGELOG.rst Change-Id: I63cc0b02f50166dcf74dced8a31a15643cb035d4 --- HISTORY.rst | 8 ++++++++ ROADMAP.rst | 4 +++- pywikibot/__metadata__.py | 2 +- scripts/CHANGELOG.rst | 8 ++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 5ae6d5f10f..be53ccc215 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,14 @@ Release History =============== +10.3.1 +------ +*08 August 2025* + +* Add support for tlwikisource (:phab:`T388656`) +* i18n updates + + 10.3.0 ------ *03 August 2025* diff --git a/ROADMAP.rst b/ROADMAP.rst index 82d9331e0a..9be82b740e 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,9 +1,11 @@ Current Release Changes ======================= +* Cleanup :mod:`setup` module (:phab:`T396356`) * Implement :meth:`pywikibot.ItemPage.get_best_claim` (:phab:`T400610`) * Add *expiry* parameter to :meth:`BasePage.watch()` and - :meth:`Site.watch()` (:phab:`T330839`) + :meth:`Site.watch()`; fix the methods to return False if + page is missing and no expiry is set (:phab:`T330839`) Deprecations diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index 36b5043e4b..72ace79c9c 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.4.0.dev0' +__version__ = '10.4.0.dev1' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' diff --git a/scripts/CHANGELOG.rst b/scripts/CHANGELOG.rst index 1c93e6c7c6..4c90f443df 100644 --- a/scripts/CHANGELOG.rst +++ b/scripts/CHANGELOG.rst @@ -1,6 +1,14 @@ Scripts Changelog ================= +10.4.0 +------ + +interwiki +^^^^^^^^^ + +* Clarify ``-localonly`` option behavior and help text (:phab:`T57257`) + 10.3.0 ------ From 41f3d5cda517f525e32fc7dce578a0de4c8f6b12 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 8 Aug 2025 16:52:27 +0200 Subject: [PATCH 099/279] Tests: Update pre-commit hooks Change-Id: I93e237df273fbb09e291fa9c6f4d3cf4559fd7bd --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9d2d3f483..213820467c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.7 + rev: v0.12.8 hooks: - id: ruff-check alias: ruff From 285503fb7d5acda7fb873c3722f29ad9d3ff885c Mon Sep 17 00:00:00 2001 From: Strainu Date: Thu, 31 Jul 2025 19:17:55 +0300 Subject: [PATCH 100/279] Add get_value_at_timestamp API to ItemPage Bug: T400612 Change-Id: I61e9396a29caad927ce7b3bdfca35f9a58b98c55 Signed-off-by: Strainu --- pywikibot/family.py | 10 ++++++ pywikibot/page/_wikibase.py | 62 +++++++++++++++++++++++++++++++++++++ tests/wikibase_tests.py | 28 +++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/pywikibot/family.py b/pywikibot/family.py index 0c4a9794b1..9197646a04 100644 --- a/pywikibot/family.py +++ b/pywikibot/family.py @@ -1111,6 +1111,16 @@ class DefaultWikibaseFamily(WikibaseFamily): .. versionadded:: 8.2 """ + @property + def interval_start_property(self) -> str: + """Return the property for the start of an interval.""" + return 'P580' + + @property + def interval_end_property(self) -> str: + """Return the property for the end of an interval.""" + return 'P582' + def calendarmodel(self, code) -> str: """Default calendar model for WbTime datatype.""" return 'http://www.wikidata.org/entity/Q1985727' diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py index 3c1df0abc0..9c5803275d 100644 --- a/pywikibot/page/_wikibase.py +++ b/pywikibot/page/_wikibase.py @@ -1358,6 +1358,7 @@ def get_best_claim(self, prop: str) -> pywikibot.Claim | None: :raises UnknownExtensionError: site has no Wikibase extension """ + def find_best_claim(claims): """Find the first best ranked claim.""" index = None @@ -1374,6 +1375,67 @@ def find_best_claim(claims): return find_best_claim(self.claims[prop]) return None + def get_value_at_timestamp( + self, + prop: str, + timestamp: pywikibot.WbTime, + lang: str = 'en' + ): + """Return the best value for this page at a given timestamp. + + :param prop: property id, "P###" + :param timestamp: the timestamp to check the value at + :param lang: the language to return the value in + :return: WbRepresentation object given by Wikibase property number for + this page object and valid for the given timestamp and language. + :rtype: pywikibot.WbRepresentation or None + + :raises NoWikibaseEntityError: site has no time interval properties + """ + if not hasattr(self.site.family, 'interval_start_property') or \ + not hasattr(self.site.family, 'interval_end_property'): + raise NoWikibaseEntityError( + f'{self.site.family} does not have time interval properties') + startp = self.site.family.interval_start_property + endp = self.site.family.interval_end_property + + def timestamp_in_interval(p, ts): + """Check if timestamp is within the qualifiers.""" + q1 = p.qualifiers.get(startp, []) + q2 = p.qualifiers.get(endp, []) + d1 = d2 = None + if q1: + d1 = q1[0].getTarget() + if q2: + d2 = q2[0].getTarget() + if d1 and d2: + return d1 <= ts <= d2 + if d1: + return d1 <= ts + if d2: + return d2 >= ts + return False + + def find_value_at_timestamp(claims, ts, language): + """Find the first best ranked claim at a given timestamp.""" + sorted_claims = sorted(claims, + key=lambda c: c.qualifiers.get(startp)[ + 0].getTarget() if c.qualifiers.get( + startp) else pywikibot.WbTime(0), + reverse=True) + for claim in sorted_claims: + if timestamp_in_interval(claim, ts): + if (claim.type == 'monolingualtext' + and claim.getTarget().language != language): + continue + else: + return claim.getTarget() + return None + + if prop in self.claims: + return find_value_at_timestamp(self.claims[prop], timestamp, lang) + return None + class Property: diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py index 47a736d20d..e5fbac6832 100755 --- a/tests/wikibase_tests.py +++ b/tests/wikibase_tests.py @@ -1509,6 +1509,34 @@ def test_get_best_claim(self) -> None: self.assertEqual(item.get_best_claim('P17').getTarget(), pywikibot.ItemPage(wikidata, 'Q142')) + def test_get_value_at_timestamp(self) -> None: + """Test getting the value of a claim at a specific timestamp.""" + wikidata = self.get_repo() + item = pywikibot.ItemPage(wikidata, 'Q90') + item.get() + wbtime = pywikibot.WbTime(year=2021, month=1, day=1, site=wikidata) + claim = item.get_value_at_timestamp('P17', wbtime) + self.assertEqual(claim, pywikibot.ItemPage(wikidata, 'Q142')) + + def test_with_monolingual_good_language(self) -> None: + """Test getting a monolingual text claim with a good language.""" + wikidata = self.get_repo() + item = pywikibot.ItemPage(wikidata, 'Q183') + item.get() + wbtime = pywikibot.WbTime(year=2021, month=1, day=1, site=wikidata) + claim = item.get_value_at_timestamp('P1448', wbtime, 'ru') + self.assertIsInstance(claim, pywikibot.WbMonolingualText) + self.assertEqual(claim.language, 'ru') + + def test_with_monolingual_wrong_language(self) -> None: + """Test getting a monolingual text claim with a good language.""" + wikidata = self.get_repo() + item = pywikibot.ItemPage(wikidata, 'Q183') + item.get() + wbtime = pywikibot.WbTime(year=2021, month=1, day=1, site=wikidata) + claim = item.get_value_at_timestamp('P1448', wbtime, 'en') + self.assertIsNone(claim, None) + if __name__ == '__main__': with suppress(SystemExit): From 85c1fc55ee6b848bd876dfb1891d47f2b321333f Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 10 Aug 2025 13:46:39 +0200 Subject: [PATCH 101/279] [bugfix] Pass site to WbTime initializer in get_value_at_timestamp - Pass self.site to WbTime initializer to avoid creating a datasite from the default site, which may cause failures. - update docstrings and type annotations for clarity. - Simplify helper functions and property checks for better readability. Bug: T401546 Change-Id: Ib6a69a1b34dc30d664fa0cdfeb8726d5ecf17ce4 --- pywikibot/page/_wikibase.py | 40 ++++++++++++++++++++----------------- tests/wikibase_tests.py | 2 +- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py index 9c5803275d..b328e45a96 100644 --- a/pywikibot/page/_wikibase.py +++ b/pywikibot/page/_wikibase.py @@ -1380,24 +1380,27 @@ def get_value_at_timestamp( prop: str, timestamp: pywikibot.WbTime, lang: str = 'en' - ): + ) -> pywikibot.WbRepresentation | None: """Return the best value for this page at a given timestamp. + .. versionadded:: 10.4 + :param prop: property id, "P###" :param timestamp: the timestamp to check the value at :param lang: the language to return the value in - :return: WbRepresentation object given by Wikibase property number for - this page object and valid for the given timestamp and language. - :rtype: pywikibot.WbRepresentation or None + :return: :class:`pywikibot.WbRepresentation` object given by + Wikibase property number for this page object and valid for + the given timestamp and language. :raises NoWikibaseEntityError: site has no time interval properties """ - if not hasattr(self.site.family, 'interval_start_property') or \ - not hasattr(self.site.family, 'interval_end_property'): + fam = self.site.family + if not hasattr(fam, 'interval_start_property') or \ + not hasattr(fam, 'interval_end_property'): raise NoWikibaseEntityError( - f'{self.site.family} does not have time interval properties') - startp = self.site.family.interval_start_property - endp = self.site.family.interval_end_property + f'{fam} does not have time interval properties') + + startp, endp = fam.interval_start_property, fam.interval_end_property def timestamp_in_interval(p, ts): """Check if timestamp is within the qualifiers.""" @@ -1418,22 +1421,23 @@ def timestamp_in_interval(p, ts): def find_value_at_timestamp(claims, ts, language): """Find the first best ranked claim at a given timestamp.""" - sorted_claims = sorted(claims, - key=lambda c: c.qualifiers.get(startp)[ - 0].getTarget() if c.qualifiers.get( - startp) else pywikibot.WbTime(0), - reverse=True) + sorted_claims = sorted( + claims, + key=(lambda c: c.qualifiers.get(startp)[0].getTarget() + if c.qualifiers.get(startp) + else pywikibot.WbTime(0, site=self.site)), + reverse=True + ) for claim in sorted_claims: if timestamp_in_interval(claim, ts): - if (claim.type == 'monolingualtext' - and claim.getTarget().language != language): - continue - else: + if (claim.type != 'monolingualtext' + or claim.getTarget().language == language): return claim.getTarget() return None if prop in self.claims: return find_value_at_timestamp(self.claims[prop], timestamp, lang) + return None diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py index e5fbac6832..eb8c7dfa5d 100755 --- a/tests/wikibase_tests.py +++ b/tests/wikibase_tests.py @@ -1529,7 +1529,7 @@ def test_with_monolingual_good_language(self) -> None: self.assertEqual(claim.language, 'ru') def test_with_monolingual_wrong_language(self) -> None: - """Test getting a monolingual text claim with a good language.""" + """Test getting a monolingual text claim with a wrong language.""" wikidata = self.get_repo() item = pywikibot.ItemPage(wikidata, 'Q183') item.get() From 0d233b231105e7666ffa91425b985bd86b7846d8 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 10 Aug 2025 17:03:39 +0200 Subject: [PATCH 102/279] IMRP: Add type annotation to _Precision.__getitem__ Change-Id: Idb809f1478c0adc50f840be9e25277633f5194c8 --- pywikibot/_wbtypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py index 8ca75578c4..ccb8cb1a98 100644 --- a/pywikibot/_wbtypes.py +++ b/pywikibot/_wbtypes.py @@ -313,7 +313,7 @@ class _Precision(Mapping): 'second': 14, } - def __getitem__(self, key) -> int: + def __getitem__(self, key: str) -> int: if key == 'millenia': issue_deprecation_warning( f'{key!r} key for precision', "'millennium'", since='10.0.0') From 3a32707ac5bdb2c8f3bd9e5d8490e4ab218cb9db Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 11 Aug 2025 14:21:32 +0200 Subject: [PATCH 103/279] Update git submodules * Update scripts/i18n from branch 'master' to 5e5692ac6cc77113036ae7ae229bf30375ed3ee2 - Localisation updates from https://translatewiki.net. Change-Id: I49e33966740ca90ab21fa214d592f6c4630f16ee --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 23d38bc21e..5e5692ac6c 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 23d38bc21e6666666ce4bd9f8945a38383a9346e +Subproject commit 5e5692ac6cc77113036ae7ae229bf30375ed3ee2 From 01c276b136cdd732ab4f681882b3881d4ed9fb48 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 11 Aug 2025 14:51:21 +0200 Subject: [PATCH 104/279] [IMPR] Improvements for deprecate_positionals decorator - raise ValueError if a VAR_POSITIONAL parameter like *args is used - VAR_KEYWORD parameter like **kwargs is allowed - update docstring Change-Id: Ie3bcedb684d2dc17d0b3414d2e4ee633b27a231c --- pywikibot/tools/_deprecate.py | 44 ++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/pywikibot/tools/_deprecate.py b/pywikibot/tools/_deprecate.py index 310e7ca5f9..4f6ffe96e6 100644 --- a/pywikibot/tools/_deprecate.py +++ b/pywikibot/tools/_deprecate.py @@ -19,7 +19,7 @@ deprecation decorators moved to _deprecate submodule """ # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -447,10 +447,18 @@ def deprecate_positionals(since: str = ''): """Decorator for methods that issues warnings for positional arguments. This decorator allows positional arguments after keyword-only - argument syntax (:pep:`3102`) but throws a FutureWarning. The - decorator makes the needed argument updates before passing them to - the called function or method. This decorator may be used for a - deprecation period when require keyword-only arguments. + argument syntax (:pep:`3102`) but throws a ``FutureWarning``. It + automatically maps the provided positional arguments to their + corresponding keyword-only parameters before invoking the decorated + method. + + The intended use is during a deprecation period in which certain + parameters should be passed as keyword-only, allowing legacy calls + to continue working with a warning rather than immediately raising a + ``TypeError``. + + .. important:: This decorator is only supported for instance or + class methods. It does not work for standalone functions. Example: @@ -462,17 +470,21 @@ def f(posarg, *, kwarg): f('foo', 'bar') - This function call passes but throws a FutureWarning. Without - decorator a TypeError would be raised. + This function call passes but throws a ``FutureWarning``. + Without the decorator, a ``TypeError`` would be raised. + + .. caution:: The decorated function must not accept ``*args``. The + sequence of keyword-only arguments must match the sequence of the + old positional parameters, otherwise argument assignment will + fail. - .. caution:: The decorated function may not use ``*args`` or - ``**kwargs``. The sequence of keyword-only arguments must match - the sequence of the old positional arguments, otherwise the - assignment of the arguments to the keyworded arguments will fail. .. versionadded:: 9.2 + .. versionchanged:: 10.4 + Raises ``ValueError`` if method has a ``*args`` parameter. - :param since: a version string when some positional arguments were - deprecated + :param since: Mandatory version string indicating when certain + positional parameters were deprecated + :raises ValueError: If the method has an *args parameter. """ def decorator(func): """Outer wrapper. Inspect the parameters of *func*. @@ -512,6 +524,10 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: # find the first KEYWORD_ONLY index for positionals, key in enumerate(arg_keys): + if sig.parameters[key].kind == inspect.Parameter.VAR_POSITIONAL: + raise ValueError( + f'{func.__qualname__} must not have *{key} parameter') + if sig.parameters[key].kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.VAR_KEYWORD): break @@ -556,7 +572,7 @@ def wrapper(*__args, **__kw): name = obj.__full_name__ depth = get_wrapper_depth(wrapper) + 1 args, varargs, kwargs, *_ = getfullargspec(wrapper.__wrapped__) - if varargs is not None and kwargs is not None: + if varargs is not None and kwargs is not None: # pragma: no cover raise ValueError(f'{name} may not have * or ** args.') deprecated = set(__kw) & set(arg_names) if len(__args) > len(args): From 87a655a1d8e1f7a2ff0aa9988fade115aecbb7da Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 11 Aug 2025 16:36:25 +0200 Subject: [PATCH 105/279] Update git submodules * Update scripts/i18n from branch 'master' to eefb8bbcb7793020863c94104c8e7a82ca337228 - Revert "Localisation updates from https://translatewiki.net." This reverts commit 5e5692ac6cc77113036ae7ae229bf30375ed3ee2. Bug: T382659 Change-Id: I1c7b6366bd2f214c31c6ec97007f3b1d9ca1dd4e --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 5e5692ac6c..eefb8bbcb7 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 5e5692ac6cc77113036ae7ae229bf30375ed3ee2 +Subproject commit eefb8bbcb7793020863c94104c8e7a82ca337228 From 71a1a74b7962326528f4485edb2fc656655edaa1 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 11 Aug 2025 17:46:20 +0200 Subject: [PATCH 106/279] tests: some more tests for titletranslate Change-Id: I996ea55c0e0d96f3347947509f78caf36ca67fd0 --- tests/titletranslate_tests.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/titletranslate_tests.py b/tests/titletranslate_tests.py index 1eb8be5e89..eefc989eac 100755 --- a/tests/titletranslate_tests.py +++ b/tests/titletranslate_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for titletranslate module.""" # -# (C) Pywikibot team, 2022 +# (C) Pywikibot team, 2022-2025 # # Distributed under the terms of the MIT license. # @@ -38,6 +38,14 @@ def test_translate(self, key) -> None: result = translate(page=self.get_mainpage(site), auto=False, hints=['5:', 'nl,en,zh'], site=site) self.assertLength(result, 6) + result = translate(page=self.get_mainpage(site)) + self.assertIsEmpty(result) + result = translate(page=self.get_mainpage(site), hints=['nl']) + self.assertLength(result, 1) + with self.assertRaisesRegex(RuntimeError, + 'Either page or site parameter must be ' + r'given with translate\(\)'): + translate() if __name__ == '__main__': From 048ec26b76c592671984b631cff24dc081507c60 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 11 Aug 2025 18:22:34 +0200 Subject: [PATCH 107/279] Tests: some more tests for maintenance.cache Change-Id: I8594cacc47f11ca81d5ffb3bd19cf25cb2d69efc --- tests/cache_tests.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/cache_tests.py b/tests/cache_tests.py index 1c1cc7b3b2..d9671e1651 100755 --- a/tests/cache_tests.py +++ b/tests/cache_tests.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """API Request cache tests.""" # -# (C) Pywikibot team, 2012-2024 +# (C) Pywikibot team, 2012-2025 # # Distributed under the terms of the MIT license. # from __future__ import annotations +import re import unittest from pywikibot.login import LoginStatus @@ -31,6 +32,8 @@ def _check_cache_entry(self, entry) -> None: self.assertIsNotNone(entry.site._username) # pragma: no cover self.assertIsInstance(entry._params, dict) self.assertIsNotNone(entry._params) + self.assertLength(str(entry), 64) + self.assertIsNotNone(re.fullmatch(r'[0-9a-f]+', str(entry))) # TODO: more tests on entry._params, and possibly fixes needed # to make it closely replicate the original object. From 45c8c01c2f42a7abf7da50de93fbe445130b3b65 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 12 Aug 2025 10:48:13 +0200 Subject: [PATCH 108/279] Add support for new wikis - Add support for madwikisource - Add support for rkiwiki - Add support for minwikibooks - Add support for zghwiktionary Bug: T392501 Bug: T395501 Bug: T399787 Bug: T391769 Change-Id: I0cf6935d20becdb5b17e37c7425231dde28931ea --- pywikibot/families/wikibooks_family.py | 8 ++++---- pywikibot/families/wikipedia_family.py | 6 +++--- pywikibot/families/wikisource_family.py | 10 +++++----- pywikibot/families/wiktionary_family.py | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pywikibot/families/wikibooks_family.py b/pywikibot/families/wikibooks_family.py index d25044388b..e846f59ac1 100644 --- a/pywikibot/families/wikibooks_family.py +++ b/pywikibot/families/wikibooks_family.py @@ -34,10 +34,10 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'af', 'ar', 'az', 'ba', 'be', 'bg', 'bn', 'bs', 'ca', 'cs', 'cv', 'cy', 'da', 'de', 'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'fy', 'gl', 'he', 'hi', 'hr', 'hu', 'hy', 'ia', 'id', 'is', 'it', 'ja', 'ka', - 'kk', 'km', 'ko', 'ku', 'ky', 'la', 'li', 'lt', 'mg', 'mk', 'ml', 'mr', - 'ms', 'ne', 'nl', 'no', 'oc', 'pa', 'pl', 'pt', 'ro', 'ru', 'sa', - 'shn', 'si', 'sk', 'sl', 'sq', 'sr', 'sv', 'ta', 'te', 'tg', 'th', - 'tl', 'tr', 'tt', 'uk', 'ur', 'vi', 'zh', + 'kk', 'km', 'ko', 'ku', 'ky', 'la', 'li', 'lt', 'mg', 'min', 'mk', + 'ml', 'mr', 'ms', 'ne', 'nl', 'no', 'oc', 'pa', 'pl', 'pt', 'ro', 'ru', + 'sa', 'shn', 'si', 'sk', 'sl', 'sq', 'sr', 'sv', 'ta', 'te', 'tg', + 'th', 'tl', 'tr', 'tt', 'uk', 'ur', 'vi', 'zh', } category_redirect_templates = { diff --git a/pywikibot/families/wikipedia_family.py b/pywikibot/families/wikipedia_family.py index a546d23144..6a546f85ce 100644 --- a/pywikibot/families/wikipedia_family.py +++ b/pywikibot/families/wikipedia_family.py @@ -51,9 +51,9 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'nds', 'nds-nl', 'ne', 'new', 'nia', 'nl', 'nn', 'no', 'nov', 'nqo', 'nr', 'nrm', 'nso', 'nup', 'nv', 'ny', 'oc', 'olo', 'om', 'or', 'os', 'pa', 'pag', 'pam', 'pap', 'pcd', 'pcm', 'pdc', 'pfl', 'pi', 'pl', - 'pms', 'pnb', 'pnt', 'ps', 'pt', 'pwn', 'qu', 'rm', 'rmy', 'rn', 'ro', - 'roa-rup', 'roa-tara', 'rsk', 'ru', 'rue', 'rw', 'sa', 'sah', 'sat', - 'sc', 'scn', 'sco', 'sd', 'se', 'sg', 'sh', 'shi', 'shn', 'si', + 'pms', 'pnb', 'pnt', 'ps', 'pt', 'pwn', 'qu', 'rki', 'rm', 'rmy', 'rn', + 'ro', 'roa-rup', 'roa-tara', 'rsk', 'ru', 'rue', 'rw', 'sa', 'sah', + 'sat', 'sc', 'scn', 'sco', 'sd', 'se', 'sg', 'sh', 'shi', 'shn', 'si', 'simple', 'sk', 'skr', 'sl', 'sm', 'smn', 'sn', 'so', 'sq', 'sr', 'srn', 'ss', 'st', 'stq', 'su', 'sv', 'sw', 'syl', 'szl', 'szy', 'ta', 'tay', 'tcy', 'tdd', 'te', 'tet', 'tg', 'th', 'ti', 'tig', 'tk', 'tl', diff --git a/pywikibot/families/wikisource_family.py b/pywikibot/families/wikisource_family.py index d096fb7fc0..d11963aca9 100644 --- a/pywikibot/families/wikisource_family.py +++ b/pywikibot/families/wikisource_family.py @@ -30,11 +30,11 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'ar', 'as', 'az', 'ban', 'bcl', 'be', 'bg', 'bn', 'br', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fo', 'fr', 'gl', 'gu', 'he', 'hi', 'hr', 'hu', 'hy', 'id', 'is', 'it', - 'ja', 'jv', 'ka', 'kn', 'ko', 'la', 'li', 'lij', 'lt', 'mk', 'ml', - 'mr', 'ms', 'mul', 'my', 'nap', 'nl', 'no', 'or', 'pa', 'pl', 'pms', - 'pt', 'ro', 'ru', 'sa', 'sah', 'sk', 'sl', 'sr', 'su', 'sv', 'ta', - 'tcy', 'te', 'th', 'tl', 'tr', 'uk', 'vec', 'vi', 'wa', 'yi', 'zh', - 'zh-min-nan', + 'ja', 'jv', 'ka', 'kn', 'ko', 'la', 'li', 'lij', 'lt', 'mad', 'mk', + 'ml', 'mr', 'ms', 'mul', 'my', 'nap', 'nl', 'no', 'or', 'pa', 'pl', + 'pms', 'pt', 'ro', 'ru', 'sa', 'sah', 'sk', 'sl', 'sr', 'su', 'sv', + 'ta', 'tcy', 'te', 'th', 'tl', 'tr', 'uk', 'vec', 'vi', 'wa', 'yi', + 'zh', 'zh-min-nan', } # Sites we want to edit but not count as real languages diff --git a/pywikibot/families/wiktionary_family.py b/pywikibot/families/wiktionary_family.py index b9647c0d6c..aab65518ab 100644 --- a/pywikibot/families/wiktionary_family.py +++ b/pywikibot/families/wiktionary_family.py @@ -49,7 +49,7 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'simple', 'sk', 'skr', 'sl', 'sm', 'so', 'sq', 'sr', 'ss', 'st', 'su', 'sv', 'sw', 'ta', 'tcy', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tn', 'tpi', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vec', 'vi', 'vo', - 'wa', 'wo', 'yi', 'yue', 'zh', 'zh-min-nan', 'zu', + 'wa', 'wo', 'yi', 'yue', 'zgh', 'zh', 'zh-min-nan', 'zu', } category_redirect_templates = { From 2856641887c07402a1970f89f3bcb06275d35509 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 12 Aug 2025 11:00:56 +0200 Subject: [PATCH 109/279] IMPR: Add help options for addwikis script The old 'help' is missleading; show a deprecation warning for it. Change-Id: I3d8d1b32b6ba9b5dfda36d3595addcf4ab2501a0 --- scripts/maintenance/addwikis.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/maintenance/addwikis.py b/scripts/maintenance/addwikis.py index b7cfaf3ba1..11784f4659 100755 --- a/scripts/maintenance/addwikis.py +++ b/scripts/maintenance/addwikis.py @@ -7,9 +7,13 @@ .. versionadded:: 9.2 +.. versionchanged:: 10.4 + The options ``-h``, ``-help`` and ``--help`` display the help message. +.. deprecated:: 10.4 + The ``help`` option """ # -# (C) Pywikibot team, 2024 +# (C) Pywikibot team, 2024-2025 # # Distributed under the terms of the MIT license. # @@ -20,7 +24,9 @@ from pathlib import Path import pywikibot +from pywikibot.exceptions import ArgumentDeprecationWarning from pywikibot.family import Family +from pywikibot.tools import issue_deprecation_warning # supported families by this script @@ -87,7 +93,14 @@ def main(*args: str) -> None: for arg in args: if arg.startswith('-family'): family = arg.split(':')[1] - elif arg == 'help': + elif arg in ('help', '-h', '-help', '--help'): + if arg == 'help': + issue_deprecation_warning( + "'help' option", + "'-h', '-help' or '--help'", + since='10.4.0', + warning_class=ArgumentDeprecationWarning + ) pywikibot.show_help() return else: From dcf9a0f5e00dab037c09e81620fc4cfd842a34ca Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 12 Aug 2025 13:22:31 +0200 Subject: [PATCH 110/279] [10.4.0] Update version after stable 10.3.2 was published Also update ROADMAP.rst and HISTORY.rst; fix spelling mistakes. Change-Id: I1971bffda644d9734be2ace8ecd10191d54bde54 --- HISTORY.rst | 9 +++++++++ ROADMAP.rst | 11 +++++++---- pywikibot/__metadata__.py | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index be53ccc215..0559415853 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,15 @@ Release History =============== +10.3.2 +------ +*12 August 2025* + +* Add support for zghwiktionary, madwikisource, rkiwiki, minwikibooks + (:phab:`T391769`, :phab:`T392501`, :phab:`T395501`, :phab:`T399787`) +* i18n updates + + 10.3.1 ------ *08 August 2025* diff --git a/ROADMAP.rst b/ROADMAP.rst index 9be82b740e..059e3be30f 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,6 +1,8 @@ Current Release Changes ======================= +* Add :meth:`get_value_at_timestamp()` API + to :class:`pywikibot.ItemPage` (:phab:`T400612`) * Cleanup :mod:`setup` module (:phab:`T396356`) * Implement :meth:`pywikibot.ItemPage.get_best_claim` (:phab:`T400610`) * Add *expiry* parameter to :meth:`BasePage.watch()` and @@ -14,11 +16,12 @@ Deprecations Pending removal in Pywikibot 13 ------------------------------- -* 10.3.0: :meth:`throttle.Trottle.getDelay` and :meth:`throttle.Trottle.setDelays` were renamed; the - old methods will be removed (:phab:`T289318`) -* 10.3.0: :attr:`throttle.Trottle.next_multiplicity` attribute is unused and will be removed +* 10.3.0: :meth:`throttle.Throttle.getDelay` and :meth:`throttle.Throttle.setDelays` were renamed to + :meth:`get_delay()` and :meth:`set_delays() + `; the old methods will be removed (:phab:`T289318`) +* 10.3.0: :attr:`throttle.Throttle.next_multiplicity` attribute is unused and will be removed (:phab:`T289318`) -* 10.3.0: *requestsize* parameter of :class:`throttle.Trottle` call is deprecated and will be +* 10.3.0: *requestsize* parameter of :class:`throttle.Throttle` call is deprecated and will be dropped (:phab:`T289318`) * 10.3.0: :func:`textlib.to_latin_digits` will be removed in favour of :func:`textlib.to_ascii_digits`, ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index 72ace79c9c..663b66e0db 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.4.0.dev1' +__version__ = '10.4.0.dev2' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' From c5ced0b16a2da6f358b2adc096244ff6f39de014 Mon Sep 17 00:00:00 2001 From: Xqt Date: Tue, 12 Aug 2025 18:46:30 +0000 Subject: [PATCH 111/279] Tests: use Python 3.13 for some CI tests Change-Id: I3c48f65f3377dbbf44f7e8dfeb2ca0bec9f1e82f Signed-off-by: Xqt --- tox.ini | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 6df5ee2e4a..ec66622c91 100644 --- a/tox.ini +++ b/tox.ini @@ -19,6 +19,7 @@ basepython = py310: python3.10 py311: python3.11 py312: python3.12 + py313: python3.13 pypy: pypy3 setenv = VIRTUAL_ENV={envdir} @@ -68,7 +69,7 @@ commands = pytest --mypy -m mypy pywikibot [testenv:commit-message] -basepython = python3.8 +basepython = python3.9 deps = commit-message-validator commands = commit-message-validator @@ -91,7 +92,7 @@ deps = commands = {posargs} [testenv:doc] -basepython = python3.12 +basepython = python3.13 commands = sphinx-build -M html ./docs ./docs/_build -j auto deps = @@ -99,7 +100,7 @@ deps = -rdocs/requirements.txt [testenv:rstcheck] -basepython = python3.12 +basepython = python3.13 commands = rstcheck --version rstcheck --report-level WARNING -r . @@ -108,7 +109,7 @@ deps = -rdocs/requirements.txt [testenv:sphinx] -basepython = python3.12 +basepython = python3.13 commands = sphinx-build -M html ./docs ./docs/_build -j auto -D html_theme=nature deps = From f9e088179d555779e1faa96f45be4e79b244acc2 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 13 Aug 2025 15:02:15 +0200 Subject: [PATCH 112/279] [tests] Make TestDeletionBot.test_dry dry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, API calls are required for get_mainpage() and exists(). To avoid this, patch DeletionRobot.skip_page. - Use unittest.patch to patch Page.delete, Page.undelete, and DeletionRobot.skip_page. - Revert previous change and don’t use get_mainpage(). - Rename self parameter in patched functions to avoid confusion. Bug: T401039 Change-Id: I7dec85323a3537c0c67eb85913d2b57c89974c81 --- tests/deletionbot_tests.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/deletionbot_tests.py b/tests/deletionbot_tests.py index f7274f591c..f44ece565f 100755 --- a/tests/deletionbot_tests.py +++ b/tests/deletionbot_tests.py @@ -109,41 +109,41 @@ def setUpClass(cls) -> None: def setUp(self) -> None: """Set up unit test.""" - self._original_delete = pywikibot.Page.delete - self._original_undelete = pywikibot.Page.undelete - pywikibot.Page.delete = delete_dummy - pywikibot.Page.undelete = undelete_dummy super().setUp() - def tearDown(self) -> None: - """Tear down unit test.""" - pywikibot.Page.delete = self._original_delete - pywikibot.Page.undelete = self._original_undelete - super().tearDown() + patches = ( + patch.object(pywikibot.Page, 'delete', delete_dummy), + patch.object(pywikibot.Page, 'undelete', undelete_dummy), + patch.object(delete.DeletionRobot, 'skip_page', + lambda inst, page: False) + ) + for p in patches: + self.addCleanup(p.stop) + p.start() def test_dry(self) -> None: """Test dry run of bot.""" - main = self.get_mainpage().title() with empty_sites(): - delete.main(f'-page:{main}', '-always', '-summary:foo') + delete.main('-page:Main Page', '-always', '-summary:foo') self.assertEqual(self.delete_args, - [f'[[{main}]]', 'foo', False, True, True]) + ['[[Main Page]]', 'foo', False, True, True]) with empty_sites(): delete.main( '-page:FoooOoOooO', '-always', '-summary:foo', '-undelete') self.assertEqual(self.undelete_args, ['[[FoooOoOooO]]', 'foo']) -def delete_dummy(self, reason, prompt, mark, automatic_quit) -> int: +def delete_dummy(page_self, reason, prompt, mark, automatic_quit, *, + deletetalk=False) -> int: """Dummy delete method.""" - TestDeletionBot.delete_args = [self.title(as_link=True), reason, prompt, - mark, automatic_quit] + TestDeletionBot.delete_args = [page_self.title(as_link=True), reason, + prompt, mark, automatic_quit] return 0 -def undelete_dummy(self, reason) -> None: +def undelete_dummy(page_self, reason) -> None: """Dummy undelete method.""" - TestDeletionBot.undelete_args = [self.title(as_link=True), reason] + TestDeletionBot.undelete_args = [page_self.title(as_link=True), reason] if __name__ == '__main__': From 2d3b10250bfb116ad8d8d4b62d3896b4b826d1c5 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 13 Aug 2025 18:40:02 +0200 Subject: [PATCH 113/279] [login_tests-ci] set continue-on-error: true Change-Id: I6a05a535804d151cda46aad96afdae4b851abbda --- .github/workflows/login_tests-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index e86a1806e8..84e3f44fe1 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -17,6 +17,7 @@ jobs: wait_for_all: name: Wait for other workflows to finish runs-on: ubuntu-latest + continue-on-error: true steps: - name: Wait for all workflows to complete excluding this one uses: kachick/wait-other-jobs@v3.8.1 From 60aba254a55d4fe2eab21a45357dc9a5d690083d Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 12 Aug 2025 18:35:56 +0200 Subject: [PATCH 114/279] [IMPR] Add `strict` parameter to unconnected_pages() Introduce a new `strict` flag to verify that pages from Special:UnconnectedPages are still unconnected before yielding. - Pass `total` directly to querypage() when strict=False to limit API calls - Stop iteration when `total` is reached - Return nothing if total<=0 - Updated docstring with description of strict and versionchanged note - Add a new `strict` flag to UnconnectedPageGenerator - Update pagegenerators_tests.TestUnconnectedPageGenerator - Update site_generators_tests.TestUnconnectedPages - Deprecation warning for UnconnectedPageGenerator was withdrawn Bug: T401699 Change-Id: Ib88712dfd88d92d585dce4739c3696d1e9e39ae1 --- HISTORY.rst | 2 +- pywikibot/pagegenerators/_generators.py | 14 +++++++--- pywikibot/site/_extensions.py | 34 ++++++++++++++++++++++--- tests/pagegenerators_tests.py | 22 +++++----------- tests/site_generators_tests.py | 19 +++----------- 5 files changed, 53 insertions(+), 38 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 0559415853..9a4b6dc724 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2000,7 +2000,7 @@ Release History * UploadBot got a filename prefix parameter (:phab:`T170123`) * cosmetic_changes is able to remove empty sections (:phab:`T140570`) * Pywikibot is following :pep:`396` versioning -* pagegenerators AllpagesPageGenerator, CombinedPageGenerator, UnconnectedPageGenerator are deprecated +* CombinedPageGenerator is deprecated, itertools.chain can be used instead * Some DayPageGenerator parameters has been renamed * unicodedata2, httpbin and Flask dependency was removed (:phab:`T102461`, :phab:`T108068`, :phab:`T178864`, :phab:`T193383`) diff --git a/pywikibot/pagegenerators/_generators.py b/pywikibot/pagegenerators/_generators.py index 4e04b8c569..c74a972e42 100644 --- a/pywikibot/pagegenerators/_generators.py +++ b/pywikibot/pagegenerators/_generators.py @@ -282,18 +282,26 @@ def upcast(gen): def UnconnectedPageGenerator( site: BaseSite | None = None, - total: int | None = None + total: int | None = None, + *, + strict: bool = False ) -> Iterable[pywikibot.page.Page]: """Iterate Page objects for all unconnected pages to a Wikibase repository. - :param total: Maximum number of pages to retrieve in total + .. versionchanged:: + The *strict* parameter was added. + :param site: Site for generator results. + :param total: Maximum number of pages to retrieve in total + :param strict: If ``True``, verify that each page still has no data + item before yielding it. + :raises ValueError: The given site does not have Wikibase repository """ if site is None: site = pywikibot.Site() if not site.data_repository(): raise ValueError('The given site does not have Wikibase repository.') - return site.unconnected_pages(total=total) + return site.unconnected_pages(total=total, strict=strict) def FileLinksGenerator( diff --git a/pywikibot/site/_extensions.py b/pywikibot/site/_extensions.py index e30f1306d5..66d342b344 100644 --- a/pywikibot/site/_extensions.py +++ b/pywikibot/site/_extensions.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Protocol import pywikibot +from pywikibot.backports import Generator from pywikibot.data import api from pywikibot.echo import Notification from pywikibot.exceptions import ( @@ -285,14 +286,41 @@ class WikibaseClientMixin: """APISite mixin for WikibaseClient extension.""" @need_extension('WikibaseClient') - def unconnected_pages(self, total=None): + def unconnected_pages( + self, + total: int | None = None, + *, + strict: bool = False + ) -> Generator[pywikibot.Page, None, None]: """Yield Page objects from Special:UnconnectedPages. .. warning:: The retrieved pages may be connected in meantime. + To avoid this, use *strict* parameter to check. - :param total: number of pages to return + .. versionchanged:: + The *strict* parameter was added. + + :param total: Maximum number of pages to return, or ``None`` for + all. + :param strict: If ``True``, verify that each page still has no + data item before yielding it. """ - return self.querypage('UnconnectedPages', total) + if total <= 0: + return + + if not strict: + return self.querypage('UnconnectedPages', total) + + count = 0 + for page in self.querypage('UnconnectedPages'): + if total is not None and count >= total: + break + + try: + page.data_item() + except NoPageError: + yield page + count += 1 class LinterMixin: diff --git a/tests/pagegenerators_tests.py b/tests/pagegenerators_tests.py index b6397970a0..9a651d7ba7 100755 --- a/tests/pagegenerators_tests.py +++ b/tests/pagegenerators_tests.py @@ -27,7 +27,7 @@ PreloadingGenerator, WikibaseItemFilterPageGenerator, ) -from tests import join_data_path, unittest_print +from tests import join_data_path from tests.aspects import ( DefaultSiteTestCase, DeprecationTestCase, @@ -1687,26 +1687,16 @@ def test_unconnected_with_repo(self) -> None: if not site: self.skipTest('Site is not using a Wikibase repository') - pages = list(pagegenerators.UnconnectedPageGenerator(self.site, 3)) + pages = list( + pagegenerators.UnconnectedPageGenerator(self.site, 3, strict=True)) self.assertLessEqual(len(pages), 3) pattern = (fr'Page \[\[({site.sitename}:|{site.code}:)-1\]\]' r" doesn't exist\.") - found = [] for page in pages: - with self.subTest(page=page): - try: - page.data_item() - except NoPageError as e: - self.assertRegex(str(e), pattern) - else: - found.append(page) - if found: - unittest_print('connection found for ', - ', '.join(str(p) for p in found)) - - # assume that we have at least one unconnected page - self.assertLess(len(found), 3) + with self.subTest(page=page), self.assertRaisesRegex(NoPageError, + pattern): + page.data_item() def test_unconnected_without_repo(self) -> None: """Test that it raises a ValueError on sites without repository.""" diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index 1deb37b9c4..7fbf716d6d 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -689,26 +689,15 @@ def test_unconnected(self) -> None: if not site: self.skipTest('Site is not using a Wikibase repository') - pages = list(self.site.unconnected_pages(total=3)) + pages = list(self.site.unconnected_pages(total=3, strict=True)) self.assertLessEqual(len(pages), 3) pattern = (fr'Page \[\[({site.sitename}:|{site.code}:)-1\]\]' r" doesn't exist\.") - found = [] for page in pages: - with self.subTest(page=page): - try: - page.data_item() - except NoPageError as e: - self.assertRegex(str(e), pattern) - else: - found.append(page) - if found: - unittest_print('connection found for ', - ', '.join(str(p) for p in found)) - - # assume that we have at least one unconnected page - self.assertLess(len(found), 3) + with self.subTest(page=page), self.assertRaisesRegex(NoPageError, + pattern): + page.data_item() class TestSiteGeneratorsUsers(DefaultSiteTestCase): From 2918e4ed9b56e0be197e779605a5ecf3b7aa1a10 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 10 Aug 2025 16:20:06 +0200 Subject: [PATCH 115/279] [IMPR] Make Coordinate.__init__ parameters keyword-only - Enforce keyword-only parameters after lat and lon in __init__ and lazy_load parameter in get_globe_item to improve clarity - Handle possible zero division in precision calculation explained in docs - Update docstrings - Minor code cleanup and consistency improvements Change-Id: I087c9db9a1736029fe625ad67a9242fd3488d67a --- pywikibot/_wbtypes.py | 173 +++++++++++++++++++++++++++--------------- 1 file changed, 111 insertions(+), 62 deletions(-) diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py index ccb8cb1a98..9efaf589e7 100644 --- a/pywikibot/_wbtypes.py +++ b/pywikibot/_wbtypes.py @@ -12,6 +12,7 @@ import math import re from collections.abc import Mapping +from contextlib import suppress from decimal import Decimal from typing import TYPE_CHECKING, Any @@ -19,7 +20,11 @@ from pywikibot import exceptions from pywikibot.backports import Iterator from pywikibot.time import Timestamp -from pywikibot.tools import issue_deprecation_warning, remove_last_args +from pywikibot.tools import ( + deprecate_positionals, + issue_deprecation_warning, + remove_last_args, +) if TYPE_CHECKING: @@ -95,28 +100,40 @@ class Coordinate(WbRepresentation): _items = ('lat', 'lon', 'entity') - def __init__(self, lat: float, lon: float, alt: float | None = None, - precision: float | None = None, - globe: str | None = None, typ: str = '', - name: str = '', dim: int | None = None, - site: DataSite | None = None, - globe_item: ItemPageStrNoneType = None, - primary: bool = False) -> None: + @deprecate_positionals(since='10.4.0') + def __init__( + self, + lat: float, + lon: float, + *, + alt: float | None = None, + precision: float | None = None, + globe: str | None = None, + typ: str = '', + name: str = '', + dim: int | None = None, + site: DataSite | None = None, + globe_item: ItemPageStrNoneType = None, + primary: bool = False + ) -> None: """Represent a geo coordinate. - :param lat: Latitude - :param lon: Longitude - :param alt: Altitude - :param precision: precision - :param globe: Which globe the point is on - :param typ: The type of coordinate point - :param name: The name - :param dim: Dimension (in meters) - :param site: The Wikibase site - :param globe_item: The Wikibase item for the globe, or the - entity URI of this Wikibase item. Takes precedence over - 'globe' if present. - :param primary: True for a primary set of coordinates + .. versionchanged:: 10.4 + The parameters after `lat` and `lon` are now keyword-only. + + :param lat: Latitude coordinate + :param lon: Longitude coordinate + :param alt: Altitude in meters + :param precision: Precision of the coordinate + :param globe: The globe the coordinate is on (e.g. 'earth') + :param typ: Type of coordinate point + :param name: Name associated with the coordinate + :param dim: Dimension in meters used for precision calculation + :param site: The Wikibase site instance + :param globe_item: Wikibase item or entity URI for the globe; + takes precedence over *globe* + :param primary: Indicates if this is a primary coordinate set + (default: False) """ self.lat = lat self.lon = lon @@ -137,11 +154,16 @@ def __init__(self, lat: float, lon: float, alt: float | None = None, @property def entity(self) -> str: - """Return the entity uri of the globe.""" + """Return the entity URI of the globe. + + :raises CoordinateGlobeUnknownError: the globe is not supported + by Wikibase + """ if not self._entity: if self.globe not in self.site.globes(): raise exceptions.CoordinateGlobeUnknownError( f'{self.globe} is not supported in Wikibase yet.') + return self.site.globes()[self.globe] if isinstance(self._entity, pywikibot.ItemPage): @@ -152,37 +174,41 @@ def entity(self) -> str: def toWikibase(self) -> dict[str, Any]: """Export the data to a JSON object for the Wikibase API. - FIXME: Should this be in the DataSite object? - - :return: Wikibase JSON + :return: Wikibase JSON representation of the coordinate """ - return {'latitude': self.lat, - 'longitude': self.lon, - 'altitude': self.alt, - 'globe': self.entity, - 'precision': self.precision, - } + return { + 'latitude': self.lat, + 'longitude': self.lon, + 'altitude': self.alt, + 'globe': self.entity, + 'precision': self.precision, + } @classmethod def fromWikibase(cls, data: dict[str, Any], site: DataSite | None = None) -> Coordinate: - """Constructor to create an object from Wikibase's JSON output. + """Create an object from Wikibase's JSON output. - :param data: Wikibase JSON - :param site: The Wikibase site + :param data: Wikibase JSON data + :param site: The Wikibase site instance + :return: Coordinate instance """ - if site is None: - site = pywikibot.Site().data_repository() - + site = site or pywikibot.Site().data_repository() globe = None - if data['globe']: + if data.get('globe'): globes = {entity: name for name, entity in site.globes().items()} globe = globes.get(data['globe']) - return cls(data['latitude'], data['longitude'], - data['altitude'], data['precision'], - globe, site=site, globe_item=data['globe']) + return cls( + data['latitude'], + data['longitude'], + alt=data.get('altitude'), + precision=data.get('precision'), + globe=globe, + site=site, + globe_item=data.get('globe') + ) @property def precision(self) -> float | None: @@ -214,17 +240,28 @@ def precision(self) -> float | None: precision = math.degrees( self._dim / (radius * math.cos(math.radians(self.lat)))) + + :return: precision in degrees or None """ - if self._dim is None and self._precision is None: + if self._precision is not None: + return self._precision + + if self._dim is None: return None - if self._precision is None and self._dim is not None: - radius = 6378137 # TODO: Support other globes + + radius = 6378137 # Earth radius in meters (TODO: support other globes) + with suppress(ZeroDivisionError): self._precision = math.degrees( self._dim / (radius * math.cos(math.radians(self.lat)))) + return self._precision @precision.setter def precision(self, value: float) -> None: + """Set the precision value. + + :param value: precision in degrees + """ self._precision = value def precisionToDim(self) -> int | None: @@ -251,38 +288,50 @@ def precisionToDim(self) -> int | None: But this is not valid, since it returns a float value for dim which is an integer. We must round it off to the nearest integer. - Therefore:: + Therefore: + + .. code-block:: python + + dim = int(round(math.radians( + precision)*radius*math.cos(math.radians(self.lat)))) - dim = int(round(math.radians( - precision)*radius*math.cos(math.radians(self.lat)))) + :return: dimension in meters + :raises ValueError: if neither dim nor precision is set """ - if self._dim is None and self._precision is None: + if self._dim is not None: + return self._dim + + if self._precision is None: raise ValueError('No values set for dim or precision') - if self._dim is None and self._precision is not None: - radius = 6378137 - self._dim = int( - round( - math.radians(self._precision) * radius * math.cos( - math.radians(self.lat)) - ) + + radius = 6378137 + self._dim = int( + round( + math.radians(self._precision) * radius * math.cos( + math.radians(self.lat)) ) + ) return self._dim - def get_globe_item(self, repo: DataSite | None = None, + @deprecate_positionals(since='10.4.0') + def get_globe_item(self, repo: DataSite | None = None, *, lazy_load: bool = False) -> pywikibot.ItemPage: """Return the ItemPage corresponding to the globe. - Note that the globe need not be in the same data repository as - the Coordinate itself. + .. note:: The globe need not be in the same data repository as + the Coordinate itself. A successful lookup is stored as an internal value to avoid the need for repeated lookups. + .. versionchanged:: 10.4 + The *lazy_load* parameter is now keyword-only. + :param repo: the Wikibase site for the globe, if different from - that provided with the Coordinate. - :param lazy_load: Do not raise NoPage if ItemPage does not - exist. - :return: pywikibot.ItemPage + that provided with the Coordinate + :param lazy_load: Do not raise :exc:`exceptions.NoPageError` if + ItemPage does not exist + :return: pywikibot.ItemPage of the globe """ if isinstance(self._entity, pywikibot.ItemPage): return self._entity From e54edac3b7a00e86718769ba87d2457e43f2d3fd Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 14 Aug 2025 13:49:51 +0200 Subject: [PATCH 116/279] Tests: use str.replace instead of re.sub for plain string replacement Change-Id: Iad4de0afd8c285f16b6fde66fbe5935bbc1530eb --- tests/link_tests.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/link_tests.py b/tests/link_tests.py index ae6f9bd5f1..25e0160248 100755 --- a/tests/link_tests.py +++ b/tests/link_tests.py @@ -72,10 +72,11 @@ def replaced(self, iterable): for items in iterable: if isinstance(items, str): items = [items] - items = [re.sub(' ', - self.site.family.title_delimiter_and_aliases[0], - item) - for item in items] + items = [ + item.replace(' ', + self.site.family.title_delimiter_and_aliases[0]) + for item in items + ] if len(items) == 1: items = items[0] yield items From 3d7a57d0a87d7147e1f7af16364afd1093514ed0 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 14 Aug 2025 14:22:40 +0200 Subject: [PATCH 117/279] Update git submodules * Update scripts/i18n from branch 'master' to 220c796642a2a3f92a0e563ee6cfb7b306fa5bc9 - Localisation updates from https://translatewiki.net. Change-Id: I4752bf445598faee9d2cf6ea2fd8773b32d60114 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index eefb8bbcb7..220c796642 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit eefb8bbcb7793020863c94104c8e7a82ca337228 +Subproject commit 220c796642a2a3f92a0e563ee6cfb7b306fa5bc9 From af15146e3880fb6444369071e07b59779842ea93 Mon Sep 17 00:00:00 2001 From: Xqt Date: Thu, 14 Aug 2025 15:33:23 +0000 Subject: [PATCH 118/279] Update git submodules * Update scripts/i18n from branch 'master' to c575cb711ba3a582a7861fec9fd085616a959b45 - Revert "Localisation updates from https://translatewiki.net." This reverts commit 220c796642a2a3f92a0e563ee6cfb7b306fa5bc9. Reason for revert: T382659 Bug: T382659 Change-Id: Iadec801ff6b58107b887dcf6a60d34c486d8822c --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 220c796642..c575cb711b 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 220c796642a2a3f92a0e563ee6cfb7b306fa5bc9 +Subproject commit c575cb711ba3a582a7861fec9fd085616a959b45 From 71fa03d7f57d3e4cbc7bd39fbfd7d0d59c381bd0 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 14 Aug 2025 17:48:42 +0200 Subject: [PATCH 119/279] Update git submodules * Update scripts/i18n from branch 'master' to a9cc37744f15b0c2f292f1d764e161ab85efe6f7 - Localisation updates from https://translatewiki.net. Change-Id: I19127717139f2d0d5f7428984b44bd648f329fdd --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index c575cb711b..a9cc37744f 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit c575cb711ba3a582a7861fec9fd085616a959b45 +Subproject commit a9cc37744f15b0c2f292f1d764e161ab85efe6f7 From 849502300d1b45e5eb576ba15a55b54353b3cf4b Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 15 Aug 2025 09:17:36 +0200 Subject: [PATCH 120/279] Update git submodules * Update scripts/i18n from branch 'master' to f7d4281b7e5ea2120b78c823d0e91243388a12ae - Fix remove unneeded duplicates Change-Id: If02865c2b544d756812e1ca5e97d5a41821faf28 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index a9cc37744f..f7d4281b7e 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit a9cc37744f15b0c2f292f1d764e161ab85efe6f7 +Subproject commit f7d4281b7e5ea2120b78c823d0e91243388a12ae From 961fc59b9f004dfd24664e8d73f502cd7bb6bd0d Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 18 Aug 2025 14:21:41 +0200 Subject: [PATCH 121/279] Update git submodules * Update scripts/i18n from branch 'master' to 299ba7048e5a30cce9528ed92b957ede58f7c699 - Localisation updates from https://translatewiki.net. Change-Id: I0a9c084da437b9b811c248018a711ee854626525 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index f7d4281b7e..299ba7048e 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit f7d4281b7e5ea2120b78c823d0e91243388a12ae +Subproject commit 299ba7048e5a30cce9528ed92b957ede58f7c699 From f02fed9d1e1ebbc721c48686b22d9efd5fae18f6 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 18 Aug 2025 16:37:28 +0200 Subject: [PATCH 122/279] Update git submodules * Update scripts/i18n from branch 'master' to 5444fdf9e6707b0790e96acedf337d20e91cfd01 - Remove duplicate entries Change-Id: Iaa9e044ce881f7295233a11f2f48a4977b17b142 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 299ba7048e..5444fdf9e6 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 299ba7048e5a30cce9528ed92b957ede58f7c699 +Subproject commit 5444fdf9e6707b0790e96acedf337d20e91cfd01 From 30020766c669314df4e9205374fde84247463068 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 18 Aug 2025 16:45:36 +0200 Subject: [PATCH 123/279] Update git submodules * Update scripts/i18n from branch 'master' to 13a836a97adb880670933e1d29a6a194900e674d - tests: Update pre-commit hooks; run pre-commit with Python 3.13 Change-Id: I1aee788c1e8029c8c8986badc694813708ea940e --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 5444fdf9e6..13a836a97a 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 5444fdf9e6707b0790e96acedf337d20e91cfd01 +Subproject commit 13a836a97adb880670933e1d29a6a194900e674d From 512f365a382cfd0b91d4cd6d87c552a37743a65b Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 18 Aug 2025 10:53:43 +0200 Subject: [PATCH 124/279] Fix wikibase_tests label for Q60 was removed and tests can use the default 'mul' - Updated wikibase tests using 'mul' instead of 'en' labels - Add test_base_data to test 'en' labels and aliases with local json file - Add test_normalized_invalid_data test to test TypeError for unsupported value type in ItemPage._normalizeData method - Add BaseDataDict to Sphinx documentation Bug: T402084 Change-Id: I8640e1e79f7ddac5e3fd12e307b6691c51a2a9cf --- docs/api_ref/pywikibot.page.rst | 2 ++ tests/wikibase_tests.py | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/api_ref/pywikibot.page.rst b/docs/api_ref/pywikibot.page.rst index 7d38815dbc..0ea0b77390 100644 --- a/docs/api_ref/pywikibot.page.rst +++ b/docs/api_ref/pywikibot.page.rst @@ -32,6 +32,8 @@ .. automodule:: pywikibot.page._collections :synopsis: Structures holding data for Wikibase entities +.. autoclass:: BaseDataDict + :mod:`page.\_decorators` --- Page Decorators ============================================ diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py index eb8c7dfa5d..14ef333c7a 100755 --- a/tests/wikibase_tests.py +++ b/tests/wikibase_tests.py @@ -283,9 +283,9 @@ def test_load_item_set_id(self) -> None: self.assertNotHasAttr(item, '_content') item.get() self.assertHasAttr(item, '_content') - self.assertIn('en', item.labels) + self.assertIn('mul', item.labels) # label could change - self.assertIn(item.labels['en'], ['New York', 'New York City']) + self.assertIn(item.labels['mul'], ['New York', 'New York City']) self.assertEqual(item.title(), 'Q60') def test_reuse_item_set_id(self) -> None: @@ -300,7 +300,7 @@ def test_reuse_item_set_id(self) -> None: wikidata = self.get_repo() item = ItemPage(wikidata, 'Q60') item.get() - self.assertIn(item.labels['en'], label) + self.assertIn(item.labels['mul'], label) # When the id attribute is modified, the ItemPage goes into # an inconsistent state. @@ -312,7 +312,7 @@ def test_reuse_item_set_id(self) -> None: # it doesn't help to clear this piece of saved state. del item._content # The labels are not updated; assertion showing undesirable behaviour: - self.assertIn(item.labels['en'], label) + self.assertIn(item.labels['mul'], label) def test_empty_item(self) -> None: """Test empty wikibase item. @@ -1127,6 +1127,14 @@ def test_normalized_data(self) -> None: copy.deepcopy(self.data_out)) self.assertEqual(response, self.data_out) + def test_normalized_invalid_data(self) -> None: + """Test _normalizeData() method for invalid data.""" + data = copy.deepcopy(self.data_out) + data['aliases']['en'] = tuple(data['aliases']['en']) + with self.assertRaisesRegex(TypeError, + "Unsupported value type 'tuple'"): + ItemPage._normalizeData(data) + class TestPreloadingEntityGenerator(TestCase): @@ -1437,6 +1445,14 @@ def setUp(self) -> None: del self.wdp._content['lastrevid'] del self.wdp._content['pageid'] + def test_base_data(self) -> None: + """Test labels and aliases collections.""" + item = self.wdp + self.assertIn('en', item.labels) + self.assertEqual(item.labels['en'], 'New York City') + self.assertIn('en', item.aliases) + self.assertIn('NYC', item.aliases['en']) + def test_itempage_json(self) -> None: """Test itempage json.""" old = json.dumps(self.wdp._content, indent=2, sort_keys=True) From 1de2a840608ee88e924a6e0fa352f2f8e15d4e30 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 18 Aug 2025 16:53:13 +0200 Subject: [PATCH 125/279] tests: update tests running with Python 3.13 Change-Id: I92ce88930db1e214a8e5e0d6d283c3ad6fffd59e --- .github/workflows/pre-commit.yml | 1 - tox.ini | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 45b9a38355..1e00a7da77 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -22,7 +22,6 @@ jobs: fail-fast: false matrix: python-version: - - '3.13' - 3.14-dev - 3.15-dev steps: diff --git a/tox.ini b/tox.ini index ec66622c91..0e671367e2 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ skipsdist = True skip_missing_interpreters = True envlist = commit-message - lint-py{39,312} + lint-py{39,313} [params] # Note: tox 4 does not support multiple lines when doing parameters @@ -38,7 +38,7 @@ commands = deeptest: python {[params]generate_user_files} deeptest-py38: python -m unittest discover -vv -p "*_tests.py" - deeptest-py312: pytest + deeptest-py313: pytest fasttest: python {[params]generate_user_files} fasttest: pytest --version @@ -57,9 +57,9 @@ deps = deeptest: .[html] deeptest: .[scripts] - deeptest-py312: .[wikitextparser] - deeptest-py312: pytest >= 7.0.1 - deeptest-py312: pytest-subtests != 0.14.0 + deeptest-py313: .[wikitextparser] + deeptest-py313: pytest >= 7.0.1 + deeptest-py313: pytest-subtests != 0.14.0 [testenv:typing] basepython = python3.9 From 5ef490524ccc8a411ee995d3666eb0eb4a7c963d Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 17 Aug 2025 12:04:32 +0200 Subject: [PATCH 126/279] Update pre-commit-hooks Change-Id: I06b0e9c467c1f53c62821e65887a3f0b98388717 --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 213820467c..3a2059e6d4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: commit-message-validator - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files args: @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.8 + rev: v0.12.9 hooks: - id: ruff-check alias: ruff From f94fef9fb944ae7b2c46191261d501224fb2800b Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 8 Aug 2025 15:30:28 +0200 Subject: [PATCH 127/279] 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 --- pywikibot/cosmetic_changes.py | 2 +- pywikibot/page/_basepage.py | 3 +- pywikibot/textlib.py | 113 +++++++++++++++++++++++++++++++++- tests/textlib_tests.py | 45 +++++++++++++- 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/pywikibot/cosmetic_changes.py b/pywikibot/cosmetic_changes.py index ed1380bda2..f97c7a7988 100644 --- a/pywikibot/cosmetic_changes.py +++ b/pywikibot/cosmetic_changes.py @@ -735,7 +735,7 @@ def removeEmptySections(self, text: str) -> str: 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 6e3adfbebe..1e7ca3f488 100644 --- a/pywikibot/page/_basepage.py +++ b/pywikibot/page/_basepage.py @@ -391,8 +391,7 @@ def get(self, force: bool = False, get_redirect: bool = False) -> str: 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 4937bd046c..da5187f3b9 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 @@ def heading(self) -> str: 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`. + + .. 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` and :attr:`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 @@ class Content(NamedTuple): """ 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_headings(text: str) -> list[_Heading]: 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 @@ def extract_sections( '== 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 @@ def extract_sections( .. 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 f8601096d0..4f4ab28af9 100755 --- a/tests/textlib_tests.py +++ b/tests/textlib_tests.py @@ -1542,11 +1542,16 @@ def _extract_sections_tests(self, result, header, sections, footer='', 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 @@ def test_with_section_no_footer(self) -> None: '==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 @@ def test_with_h4_and_h2_sections(self) -> None: '==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 @@ def test_title(self) -> None: 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): From 6fde1aca4c4d40aa5f88213e0b22f114ac74590a Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 18 Aug 2025 10:00:59 +0200 Subject: [PATCH 128/279] don't require 18n module when a manual summary is provided if a manual summary is provided then loading the automatic translations is unnecessary and forces the user to have installed the i18n submodule. Change-Id: Ifa5f15e570094edd877c820cb50087beffd9bf55 --- scripts/replace.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/replace.py b/scripts/replace.py index 7414bfc834..176da68d1e 100755 --- a/scripts/replace.py +++ b/scripts/replace.py @@ -1006,7 +1006,9 @@ def main(*args: str) -> None: # The summary stored here won't be actually used but is only an example site = pywikibot.Site() - single_summary = None + single_summary = ( + 'Not needed' if edit_summary and edit_summary is not True else None + ) for old, new in batched(commandline_replacements, 2): replacement = Replacement(old, new) if not single_summary: From 56821d69b2a0793775e8d05faf466dbf461dbd77 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 16 Aug 2025 15:05:26 +0200 Subject: [PATCH 129/279] Suppress ResourceWarning for PyPy in TestTerminalInput.test_input_yn Bug: T402080 Change-Id: If820e5b3610fba22fc49d28343c5762490b7144e --- tests/ui_tests.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/ui_tests.py b/tests/ui_tests.py index a743e4a11c..ebfc695a34 100755 --- a/tests/ui_tests.py +++ b/tests/ui_tests.py @@ -207,13 +207,21 @@ def testInput(self) -> None: self.assertEqual(returned, 'input to read') def test_input_yn(self) -> None: - self.strin.write('\n') - self.strin.seek(0) - returned = pywikibot.input_yn('question', False, automatic_quit=False) + if platform.python_implementation() == 'PyPy': + context = suppress_warnings(r'subprocess \d+ is still running', + ResourceWarning) + else: + context = nullcontext() + with context: + self.strin.write('\n') + self.strin.seek(0) + returned = pywikibot.input_yn('question', False, + automatic_quit=False) - self.assertEqual(self.strout.getvalue(), '') - self.assertEqual(self.strerr.getvalue(), 'question ([y]es, [N]o): ') - self.assertFalse(returned) + self.assertEqual(self.strout.getvalue(), '') + self.assertEqual(self.strerr.getvalue(), + 'question ([y]es, [N]o): ') + self.assertFalse(returned) def _call_input_choice(self): rv = pywikibot.input_choice( From d63483970efb0d025af2274b541db7892ab0f242 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 10 Aug 2025 20:51:39 +0200 Subject: [PATCH 130/279] [IMPR] Refactor WbTime - refactor Initializer - add re.compiled _timestr_re class attribute for fromTimestr - refactor fromTimestamp - force keyword arguments for initializer, fromTimestr, fromTimestamp - update docstrings - tests added for normalize handlers - update other TestWbTime tests Change-Id: I2757d10b0bed2e604b4fb7c29fd0f3e393ea3fe1 --- pywikibot/_wbtypes.py | 369 +++++++++++++++++++++++++---------------- tests/wbtypes_tests.py | 35 ++-- 2 files changed, 252 insertions(+), 152 deletions(-) diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py index 9efaf589e7..eb150385c3 100644 --- a/pywikibot/_wbtypes.py +++ b/pywikibot/_wbtypes.py @@ -414,19 +414,26 @@ class WbTime(WbRepresentation): 12: 334, # Nov -> Dec: 30 days, plus 304 days in Jan -> Nov } - def __init__(self, - year: int | None = None, - month: int | None = None, - day: int | None = None, - hour: int | None = None, - minute: int | None = None, - second: int | None = None, - precision: int | str | None = None, - before: int = 0, - after: int = 0, - timezone: int = 0, - calendarmodel: str | None = None, - site: DataSite | None = None) -> None: + _timestr_re = re.compile( + r'([-+]?\d{1,16})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z') + + @deprecate_positionals(since='10.4.0') + def __init__( + self, + year: int, + month: int | None = None, + day: int | None = None, + hour: int | None = None, + minute: int | None = None, + second: int | None = None, + *, + precision: int | str | None = None, + before: int = 0, + after: int = 0, + timezone: int = 0, + calendarmodel: str | None = None, + site: DataSite | None = None + ) -> None: """Create a new WbTime object. The precision can be set by the Wikibase int value (0-14) or by @@ -455,6 +462,11 @@ def __init__(self, *precision* value 'millenia' is deprecated; 'millennium' must be used instead. + .. versionchanged:: 10.4 + The parameters except timestamp values are now keyword-only. + A TypeError is raised if *year* is not an int. Previously, a + ValueError was raised if *year* was None. + :param year: The year as a signed integer of between 1 and 16 digits. :param month: Month of the timestamp, if it exists. @@ -476,56 +488,55 @@ def __init__(self, :param site: The Wikibase site. If not provided, retrieves the data repository from the default site from user-config.py. Only used if calendarmodel is not given. + :raises TypeError: Invalid *year* type. + :raises ValueError: Invalid *precision* or *site* or default + site has no data repository. """ - if year is None: - raise ValueError('no year given') - self.precision = self.PRECISION['year'] - if month is not None: - self.precision = self.PRECISION['month'] - else: - month = 1 - if day is not None: - self.precision = self.PRECISION['day'] - else: - day = 1 - if hour is not None: - self.precision = self.PRECISION['hour'] - else: - hour = 0 - if minute is not None: - self.precision = self.PRECISION['minute'] - else: - minute = 0 - if second is not None: - self.precision = self.PRECISION['second'] - else: - second = 0 + if not isinstance(year, int): + raise TypeError(f'year must be an int, not {type(year).__name__}') + + units = [ + ('month', month, 1), + ('day', day, 1), + ('hour', hour, 0), + ('minute', minute, 0), + ('second', second, 0), + ] + + # set unit attribute values self.year = year - self.month = month - self.day = day - self.hour = hour - self.minute = minute - self.second = second + for unit, value, default in units: + setattr(self, unit, value if value is not None else default) + + if precision is None: + # Autodetection of precision based on the passed time values + prec = self.PRECISION['year'] + + for unit, value, _ in units: + if value is not None: + prec = self.PRECISION[unit] + else: + # explicit precision is given + if (isinstance(precision, int) + and precision in self.PRECISION.values()): + prec = precision + elif precision in self.PRECISION: + prec = self.PRECISION[precision] + else: + raise ValueError(f'Invalid precision: "{precision}"') + + self.precision = prec self.after = after self.before = before self.timezone = timezone if calendarmodel is None: + site = site or pywikibot.Site().data_repository() if site is None: - site = pywikibot.Site().data_repository() - if site is None: - raise ValueError( - f'Site {pywikibot.Site()} has no data repository') + raise ValueError( + f'Site {pywikibot.Site()} has no data repository') calendarmodel = site.calendarmodel() + self.calendarmodel = calendarmodel - # if precision is given it overwrites the autodetection above - if precision is not None: - if (isinstance(precision, int) - and precision in self.PRECISION.values()): - self.precision = precision - elif precision in self.PRECISION: - self.precision = self.PRECISION[precision] - else: - raise ValueError(f'Invalid precision: "{precision}"') def _getSecondsAdjusted(self) -> int: """Return an internal representation of the time object as seconds. @@ -621,60 +632,78 @@ def equal_instant(self, other: WbTime) -> bool: return self._getSecondsAdjusted() == other._getSecondsAdjusted() @classmethod - def fromTimestr(cls, - datetimestr: str, - precision: int | str = 14, - before: int = 0, - after: int = 0, - timezone: int = 0, - calendarmodel: str | None = None, - site: DataSite | None = None) -> WbTime: + @deprecate_positionals(since='10.4.0') + def fromTimestr( + cls, + datetimestr: str, + *, + precision: int | str = 14, + before: int = 0, + after: int = 0, + timezone: int = 0, + calendarmodel: str | None = None, + site: DataSite | None = None + ) -> WbTime: """Create a new WbTime object from a UTC date/time string. - The timestamp differs from ISO 8601 in that: - - * The year is always signed and having between 1 and 16 digits; - * The month, day and time are zero if they are unknown; - * The Z is discarded since time zone is determined from the timezone - param. - - :param datetimestr: Timestamp in a format resembling ISO 8601, - e.g. +2013-01-01T00:00:00Z - :param precision: The unit of the precision of the time. Defaults to - 14 (second). - :param before: Number of units after the given time it could be, if - uncertain. The unit is given by the precision. - :param after: Number of units before the given time it could be, if - uncertain. The unit is given by the precision. - :param timezone: Timezone information in minutes. + The timestamp format must match a string resembling ISO 8601 + with the following constraints: + + - Year is signed and can have between 1 and 16 digits. + - Month, day, hour, minute and second are always two digits. + They may be zero. + - Time is always in UTC and ends with ``Z``. + - Example: ``+0000000000123456-01-01T00:00:00Z``. + + .. versionchanged:: 10.4 + The parameters except *datetimestr* are now keyword-only. + + :param datetimestr: Timestamp string to parse + :param precision: The unit of the precision of the time. Defaults + to 14 (second). + :param before: Number of units after the given time it could be, + if uncertain. The unit is given by the precision. + :param after: Number of units before the given time it could be, + if uncertain. The unit is given by the precision. + :param timezone: Timezone offset in minutes. :param calendarmodel: URI identifying the calendar model. - :param site: The Wikibase site. If not provided, retrieves the data - repository from the default site from user-config.py. + :param site: The Wikibase site. If not provided, retrieves the + data repository from the default site from user-config.py. Only used if calendarmodel is not given. + :raises ValueError: If the string does not match the expected + format. """ - match = re.match(r'([-+]?\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)Z', - datetimestr) + match = cls._timestr_re.match(datetimestr) if not match: raise ValueError(f"Invalid format: '{datetimestr}'") + t = match.groups() return cls(int(t[0]), int(t[1]), int(t[2]), int(t[3]), int(t[4]), int(t[5]), - precision, before, after, timezone, calendarmodel, site) + precision=precision, before=before, after=after, + timezone=timezone, calendarmodel=calendarmodel, site=site) @classmethod - def fromTimestamp(cls, - timestamp: Timestamp, - precision: int | str = 14, - before: int = 0, - after: int = 0, - timezone: int = 0, - calendarmodel: str | None = None, - site: DataSite | None = None, - copy_timezone: bool = False) -> WbTime: + @deprecate_positionals(since='10.4.0') + def fromTimestamp( + cls, + timestamp: Timestamp, + *, + precision: int | str = 14, + before: int = 0, + after: int = 0, + timezone: int = 0, + calendarmodel: str | None = None, + site: DataSite | None = None, + copy_timezone: bool = False + ) -> WbTime: """Create a new WbTime object from a pywikibot.Timestamp. .. versionchanged:: 8.0 Added *copy_timezone* parameter. + .. versionchanged:: 10.4 + The parameters except *timestamp* are now keyword-only. + :param timestamp: Timestamp :param precision: The unit of the precision of the time. @@ -699,6 +728,89 @@ def fromTimestamp(cls, before=before, after=after, timezone=timezone, calendarmodel=calendarmodel, site=site) + @staticmethod + def _normalize_millennium(year: int) -> int: + """Round the given year to the start of its millennium. + + The rounding is performed towards positive infinity for positive + years and towards negative infinity for negative years. + + .. versionadded:: 10.4 + + :param year: The year as an integer. + :return: The first year of the millennium containing the given + year. + """ + # For negative years, floor rounds away from zero to correctly handle + # BCE dates. For positive years, ceil rounds up to the next + # millennium/century. + year_float = year / 1000 + if year_float < 0: + year = math.floor(year_float) + else: + year = math.ceil(year_float) + return year * 1000 + + @staticmethod + def _normalize_century(year: int) -> int: + """Round the given year to the start of its century. + + The rounding is performed towards positive infinity for positive + years and towards negative infinity for negative years. + + .. versionadded:: 10.4 + + :param year: The year as an integer. + :return: The first year of the century containing the given year. + """ + # For century, -1301 is the same century as -1400 but not -1401. + # Similar for 1901 and 2000 vs 2001. + year_float = year / 100 + if year_float < 0: + year = math.floor(year_float) + else: + year = math.ceil(year_float) + return year * 100 + + @staticmethod + def _normalize_decade(year: int) -> int: + """Round the given year down to the start of its decade. + + Unlike millennium or century normalization, this always + truncates towards zero. + + .. versionadded:: 10.4 + + :param year: The year as an integer. + :return: The first year of the decade containing the given year. + """ + # For decade, -1340 is the same decade as -1349 but not -1350. + # Similar for 2010 and 2019 vs 2020 + year_float = year / 10 + year = math.trunc(year_float) + return year * 10 + + @staticmethod + def _normalize_power_of_ten(year: int, precision: int) -> int: + """Round the year to the given power-of-ten precision. + + This is used for very coarse historical precision levels, where + the time unit represents a power-of-ten number of years. + + .. versionadded:: 10.4 + + :param year: The year as an integer. + :param precision: The precision level (Wikibase int value). + :return: The normalized year rounded to the nearest matching + power-of-ten boundary. + """ + # Wikidata rounds the number based on the first non-decimal digit. + # Python's round function will round -15.5 to -16, and +15.5 to +16 + # so we don't need to do anything complicated like the other + # examples. + power_of_10 = 10 ** (9 - precision) + return round(year / power_of_10) * power_of_10 + def normalize(self) -> WbTime: """Normalizes the WbTime object to account for precision. @@ -712,45 +824,24 @@ def normalize(self) -> WbTime: Normalization will delete timezone information if the precision is less than or equal to DAY. - Note: Normalized WbTime objects can only be compared to other - normalized WbTime objects of the same precision. Normalization - might make a WbTime object that was less than another WbTime object - before normalization, greater than it after normalization, or vice - versa. + .. note:: Normalized WbTime objects can only be compared to + other normalized WbTime objects of the same precision. + Normalization might make a WbTime object that was less than + another WbTime object before normalization, greater than it + after normalization, or vice versa. """ year = self.year - # This is going to get messy. - if self.PRECISION['1000000000'] <= self.precision <= self.PRECISION['10000']: # noqa: E501 - # 1000000000 == 10^9 - power_of_10 = 10 ** (9 - self.precision) - # Wikidata rounds the number based on the first non-decimal digit. - # Python's round function will round -15.5 to -16, and +15.5 to +16 - # so we don't need to do anything complicated like the other - # examples. - year = round(year / power_of_10) * power_of_10 - elif self.precision == self.PRECISION['millennium']: - # Similar situation with centuries - year_float = year / 1000 - if year_float < 0: - year = math.floor(year_float) - else: - year = math.ceil(year_float) - year *= 1000 - elif self.precision == self.PRECISION['century']: - # For century, -1301 is the same century as -1400 but not -1401. - # Similar for 1901 and 2000 vs 2001. - year_float = year / 100 - if year_float < 0: - year = math.floor(year_float) - else: - year = math.ceil(year_float) - year *= 100 - elif self.precision == self.PRECISION['decade']: - # For decade, -1340 is the same decade as -1349 but not -1350. - # Similar for 2010 and 2019 vs 2020 - year_float = year / 10 - year = math.trunc(year_float) - year *= 10 + for prec in 'millennium', 'century', 'decade': + if self.precision == self.PRECISION[prec]: + handler = getattr(self, '_normalize_' + prec) + year = handler(year) + break + else: + lower = self.PRECISION['1000000000'] + upper = self.PRECISION['10000'] + if lower <= self.precision <= upper: + year = self._normalize_power_of_ten(year, self.precision) + kwargs = { 'precision': self.precision, 'before': self.before, @@ -758,18 +849,14 @@ def normalize(self) -> WbTime: 'calendarmodel': self.calendarmodel, 'year': year } - if self.precision >= self.PRECISION['month']: - kwargs['month'] = self.month - if self.precision >= self.PRECISION['day']: - kwargs['day'] = self.day - if self.precision >= self.PRECISION['hour']: - # See T326693 - kwargs['timezone'] = self.timezone - kwargs['hour'] = self.hour - if self.precision >= self.PRECISION['minute']: - kwargs['minute'] = self.minute - if self.precision >= self.PRECISION['second']: - kwargs['second'] = self.second + + for prec in 'month', 'day', 'hour', 'minute', 'second': + if self.precision >= self.PRECISION[prec]: + kwargs[prec] = getattr(self, prec) + if prec == 'hour': + # Add timezone, see T326693 + kwargs['timezone'] = self.timezone + return type(self)(**kwargs) @remove_last_args(['normalize']) # since 8.2.0 diff --git a/tests/wbtypes_tests.py b/tests/wbtypes_tests.py index a321f9211f..a136ba696f 100755 --- a/tests/wbtypes_tests.py +++ b/tests/wbtypes_tests.py @@ -335,12 +335,21 @@ def test_WbTime_normalization(self) -> None: self.assertNotEqual(t11, t12) self.assertEqual(t11_normalized, t12_normalized) self.assertEqual(t13.normalize().timezone, -300) + # test _normalize handler functions + self.assertEqual(pywikibot.WbTime._normalize_millennium(1301), 2000) + self.assertEqual(pywikibot.WbTime._normalize_millennium(-1301), -2000) + self.assertEqual(pywikibot.WbTime._normalize_century(1301), 1400) + self.assertEqual(pywikibot.WbTime._normalize_century(-1301), -1400) + self.assertEqual(pywikibot.WbTime._normalize_decade(1301), 1300) + self.assertEqual(pywikibot.WbTime._normalize_decade(-1301), -1300) + self.assertEqual( + pywikibot.WbTime._normalize_power_of_ten(123456, 7), 123500) + self.assertEqual( + pywikibot.WbTime._normalize_power_of_ten(-987654, 3), -1000000) def test_WbTime_normalization_very_low_precision(self) -> None: """Test WbTime normalization with very low precision.""" repo = self.get_repo() - # flake8 is being annoying, so to reduce line length, I'll make - # some aliases here year_10000 = pywikibot.WbTime.PRECISION['10000'] year_100000 = pywikibot.WbTime.PRECISION['100000'] year_1000000 = pywikibot.WbTime.PRECISION['1000000'] @@ -423,15 +432,18 @@ def test_WbTime_timestamp(self) -> None: def test_WbTime_errors(self) -> None: """Test WbTime precision errors.""" repo = self.get_repo() - regex = r'^no year given$' - with self.assertRaisesRegex(ValueError, regex): + regex = '^year must be an int, not NoneType$' + with self.assertRaisesRegex(TypeError, regex): + pywikibot.WbTime(None, site=repo, precision=15) + regex = "missing 1 required positional argument: 'year'" + with self.assertRaisesRegex(TypeError, regex): pywikibot.WbTime(site=repo, precision=15) - with self.assertRaisesRegex(ValueError, regex): + with self.assertRaisesRegex(TypeError, regex): pywikibot.WbTime(site=repo, precision='invalid_precision') - regex = r'^Invalid precision: "15"$' + regex = '^Invalid precision: "15"$' with self.assertRaisesRegex(ValueError, regex): pywikibot.WbTime(site=repo, year=2020, precision=15) - regex = r'^Invalid precision: "invalid_precision"$' + regex = '^Invalid precision: "invalid_precision"$' with self.assertRaisesRegex(ValueError, regex): pywikibot.WbTime(site=repo, year=2020, precision='invalid_precision') @@ -460,10 +472,11 @@ def test_comparison(self) -> None: self.assertEqual(t2.second, 0) self.assertEqual(t1.toTimestr(), '+00000002010-01-01T12:43:00Z') self.assertEqual(t2.toTimestr(), '-00000002005-01-01T16:45:00Z') - self.assertRaises(ValueError, pywikibot.WbTime, site=repo, - precision=15) - self.assertRaises(ValueError, pywikibot.WbTime, site=repo, - precision='invalid_precision') + with self.assertRaisesRegex(ValueError, 'Invalid precision: "15"'): + pywikibot.WbTime(0, site=repo, precision=15) + with self.assertRaisesRegex(ValueError, + 'Invalid precision: "invalid_precision"'): + pywikibot.WbTime(0, site=repo, precision='invalid_precision') self.assertIsInstance(t1.toTimestamp(), pywikibot.Timestamp) self.assertRaises(ValueError, t2.toTimestamp) From 036a200cb868284e5d603e1a9d0f3bed1c6763c4 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 10 Aug 2025 14:30:10 +0200 Subject: [PATCH 131/279] fix: improve WbRepresentation __hash__, __repr__ and remove __ne__ - Use json.dumps(..., sort_keys=True) in __hash__ to handle nested dicts from toWikibase(), ensuring consistent hashability. - Simplify __repr__ and use repr for attributes - Remove __ne__ method; Python 3 derives it automatically from __eq__. - update tests Change-Id: I710b4d106aef5c5c6459019d4564cfecb8555658 --- pywikibot/_wbtypes.py | 31 +++++++++++++++++++----------- tests/wbtypes_tests.py | 43 ++++++++++++++++++++++-------------------- 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py index eb150385c3..e3ed6b0013 100644 --- a/pywikibot/_wbtypes.py +++ b/pywikibot/_wbtypes.py @@ -51,7 +51,7 @@ class WbRepresentation(abc.ABC): @abc.abstractmethod def __init__(self) -> None: - """Constructor.""" + """Initializer.""" raise NotImplementedError @abc.abstractmethod @@ -70,28 +70,37 @@ def fromWikibase( raise NotImplementedError def __str__(self) -> str: - return json.dumps(self.toWikibase(), indent=4, sort_keys=True, - separators=(',', ': ')) + return json.dumps( + self.toWikibase(), + indent=4, + sort_keys=True, + separators=(',', ': ') + ) def __repr__(self) -> str: + """String representation of this object. + + .. versionchanged:: 10.4 + Parameters are shown as representations instead of plain + strings. + + :meta public: + """ assert isinstance(self._items, tuple) assert all(isinstance(item, str) for item in self._items) - values = ((attr, getattr(self, attr)) for attr in self._items) - attrs = ', '.join(f'{attr}={value}' - for attr, value in values) - return f'{self.__class__.__name__}({attrs})' + attrs = ', '.join(f'{attr}={getattr(self, attr)!r}' + for attr in self._items) + return f'{type(self).__name__}({attrs})' def __eq__(self, other: object) -> bool: if isinstance(other, self.__class__): return self.toWikibase() == other.toWikibase() + return NotImplemented def __hash__(self) -> int: - return hash(frozenset(self.toWikibase().items())) - - def __ne__(self, other: object) -> bool: - return not self.__eq__(other) + return hash(json.dumps(self.toWikibase(), sort_keys=True)) class Coordinate(WbRepresentation): diff --git a/tests/wbtypes_tests.py b/tests/wbtypes_tests.py index a136ba696f..95ce78bfb9 100755 --- a/tests/wbtypes_tests.py +++ b/tests/wbtypes_tests.py @@ -634,18 +634,20 @@ def test_WbQuantity_string(self) -> None: def test_WbQuantity_formatting_bound(self) -> None: """Test WbQuantity formatting with bounds.""" repo = self.get_repo() - q = pywikibot.WbQuantity(amount='0.044405586', error='0', site=repo) + amount = '0.044405586' + repr_amount = repr(Decimal(amount)) + q = pywikibot.WbQuantity(amount=amount, error='0', site=repo) self.assertEqual(str(q), - '{{\n' - ' "amount": "+{val}",\n' - ' "lowerBound": "+{val}",\n' - ' "unit": "1",\n' - ' "upperBound": "+{val}"\n' - '}}'.format(val='0.044405586')) + f'{{\n' + f' "amount": "+{amount}",\n' + f' "lowerBound": "+{amount}",\n' + f' "unit": "1",\n' + f' "upperBound": "+{amount}"\n' + f'}}') self.assertEqual(repr(q), - 'WbQuantity(amount={val}, ' - 'upperBound={val}, lowerBound={val}, ' - 'unit=1)'.format(val='0.044405586')) + f'WbQuantity(amount={repr_amount}, ' + f'upperBound={repr_amount}, ' + f"lowerBound={repr_amount}, unit='1')") def test_WbQuantity_self_equality(self) -> None: """Test WbQuantity equality.""" @@ -717,18 +719,19 @@ def test_WbQuantity_unbound(self) -> None: def test_WbQuantity_formatting_unbound(self) -> None: """Test WbQuantity formatting without bounds.""" - q = pywikibot.WbQuantity(amount='0.044405586', site=self.repo) + amount = '0.044405586' + q = pywikibot.WbQuantity(amount=amount, site=self.repo) self.assertEqual(str(q), - '{{\n' - ' "amount": "+{val}",\n' - ' "lowerBound": null,\n' - ' "unit": "1",\n' - ' "upperBound": null\n' - '}}'.format(val='0.044405586')) + f'{{\n' + f' "amount": "+{amount}",\n' + f' "lowerBound": null,\n' + f' "unit": "1",\n' + f' "upperBound": null\n' + f'}}') self.assertEqual(repr(q), - 'WbQuantity(amount={val}, ' - 'upperBound=None, lowerBound=None, ' - 'unit=1)'.format(val='0.044405586')) + f'WbQuantity(amount={Decimal(amount)!r}, ' + f'upperBound=None, lowerBound=None, ' + f"unit='1')") def test_WbQuantity_fromWikibase_unbound(self) -> None: """Test WbQuantity.fromWikibase() instantiating without bounds.""" From 3a35d1b4fc6883682a57ef968e77f35c7a25c7ab Mon Sep 17 00:00:00 2001 From: Xqt Date: Tue, 19 Aug 2025 04:36:14 +0000 Subject: [PATCH 132/279] Fix: Omit deprecation warning in _wptypes Change-Id: Ice1379607b92fcc480df85a835d271c5a080ef24 Signed-off-by: Xqt --- pywikibot/_wbtypes.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py index e3ed6b0013..0aa2317008 100644 --- a/pywikibot/_wbtypes.py +++ b/pywikibot/_wbtypes.py @@ -941,9 +941,15 @@ def fromWikibase(cls, data: dict[str, Any], :param site: The Wikibase site. If not provided, retrieves the data repository from the default site from user-config.py. """ - return cls.fromTimestr(data['time'], data['precision'], - data['before'], data['after'], - data['timezone'], data['calendarmodel'], site) + return cls.fromTimestr( + data['time'], + precision=data['precision'], + before=data['before'], + after=data['after'], + timezone=data['timezone'], + calendarmodel=data['calendarmodel'], + site=site + ) class WbQuantity(WbRepresentation): From 2b9e2b331cf8db375d5ac5a08b39a96fe36e5fc1 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 20 Aug 2025 10:27:39 +0200 Subject: [PATCH 133/279] tests: Run doctest with Python 3.8 and 3.13 Bug: T348905 Change-Id: Ida2164f366067d2f28c0647dc78562d18986c0c0 --- tox.ini | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 0e671367e2..2f4fa6193b 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,8 @@ envlist = # Note: tox 4 does not support multiple lines when doing parameters # substitution. generate_user_files = -W error::UserWarning -m pwb generate_user_files -family:wikipedia -lang:test -v +# ignores: gui.py (needs tkinter), memento.py (has too many timeouts) +DOCTEST_IGNORES = --ignore-glob=*gui.py --ignore-glob=*memento.py [testenv] basepython = @@ -74,13 +76,33 @@ deps = commit-message-validator commands = commit-message-validator [testenv:doctest] +basepython = python3 +skip_install = True +allowlist_externals = tox +deps = +commands = + tox -e doctest-py38 + tox -e doctest-py313 + +[testenv:doctest-py38] basepython = python3.8 commands = + python -m pytest --version python {[params]generate_user_files} - pytest --version -# gui.py needs tkinter -# memento.py has too many timeout - pytest pywikibot --doctest-modules --ignore-glob="*gui.py" --ignore-glob="*memento.py" + python -m pytest pywikibot --doctest-modules {[params]DOCTEST_IGNORES} + +deps = + pytest >= 7.0.1 + wikitextparser + .[eventstreams] + .[mysql] + +[testenv:doctest-py313] +basepython = python3.13 +commands = + python -m pytest --version + # user files already exists from doctest-py38 run + python -m pytest pywikibot --doctest-modules {[params]DOCTEST_IGNORES} deps = pytest >= 7.0.1 From 6920a66e88f5aa7863e1047885b156ccf7cc2037 Mon Sep 17 00:00:00 2001 From: Xqt Date: Wed, 20 Aug 2025 13:10:28 +0000 Subject: [PATCH 134/279] Fix: update itemlist in TestPropertyNames.test_get_property_names Bug: T402394 Change-Id: Ic05eeea081279667bdb5cdae8c84a813620997b2 Signed-off-by: Xqt --- tests/site_tests.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/site_tests.py b/tests/site_tests.py index 937cccb8ec..2eb6207010 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -1248,8 +1248,6 @@ def test_get_property_names(self, key) -> None: 'unexpectedUnconnectedPage', 'wikibase-badge-Q17437796', 'wikibase-badge-Q17437798', - 'wikibase-badge-Q17506997', - 'wikibase-badge-Q17580674', 'wikibase-badge-Q70894304', 'wikibase_item', ): From e0bdf64cd261f6b7bb73b822b2f15426e574c773 Mon Sep 17 00:00:00 2001 From: Xqt Date: Thu, 21 Aug 2025 08:54:19 +0000 Subject: [PATCH 135/279] Coverage: Update coverage for aspects Change-Id: Idc4ce76318427d15a9cdb3699a416f4761833372 Signed-off-by: Xqt --- tests/aspects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/aspects.py b/tests/aspects.py index 0a29a4a0a6..b691abc93f 100644 --- a/tests/aspects.py +++ b/tests/aspects.py @@ -1693,7 +1693,7 @@ def assertDeprecationFile(self, filename) -> None: and 'pywikibot' not in item.filename): continue # pragma: no cover - if item.filename != filename: + if item.filename != filename: # pragma: no cover self.fail(f'expected warning filename {filename}; warning ' f'item: {item}') From b99f1cecb3e5f4fcaf35bff8e744f60b1754cab4 Mon Sep 17 00:00:00 2001 From: Xqt Date: Thu, 21 Aug 2025 10:02:30 +0000 Subject: [PATCH 136/279] CI: Adjust tox minversion Adjust tox min_version to be in sync with CI integration config where tox 3.21.4 is required Change-Id: I989e923e439e654f77fec9e0f472f8520039b44f Signed-off-by: Xqt --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 2f4fa6193b..396c5f1dbc 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] -# minversion = 1.7.2 needed for skip_missing_interpreters -minversion = 1.7.2 +minversion = 3.21 skipsdist = True skip_missing_interpreters = True envlist = From 1e722d4dc1ab44f5dedad931ef6b596217ddeb43 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 21 Aug 2025 14:22:00 +0200 Subject: [PATCH 137/279] Update git submodules * Update scripts/i18n from branch 'master' to 40fe0ff6385e27689d72b4d5507d25507bcea463 - Localisation updates from https://translatewiki.net. Change-Id: I386086671315c93d3cce0d0b22dae84c039a8379 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 13a836a97a..40fe0ff638 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 13a836a97adb880670933e1d29a6a194900e674d +Subproject commit 40fe0ff6385e27689d72b4d5507d25507bcea463 From 69e83ab4271dccfc59d1a3804163cc348fcec295 Mon Sep 17 00:00:00 2001 From: Xqt Date: Thu, 21 Aug 2025 13:07:00 +0000 Subject: [PATCH 138/279] Update git submodules * Update scripts/i18n from branch 'master' to e97d7a85ec984a193d43292c00e5544e56180eeb - Revert "Localisation updates from https://translatewiki.net." This reverts commit 40fe0ff6385e27689d72b4d5507d25507bcea463. Reason for revert: duplicates and unused languages Change-Id: I6ec8213fef6f56ce6396528cf3277943db5f1395 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 40fe0ff638..e97d7a85ec 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 40fe0ff6385e27689d72b4d5507d25507bcea463 +Subproject commit e97d7a85ec984a193d43292c00e5544e56180eeb From 18f9e8b3c1f370c87ff172a75a249665eebc3d6f Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 22 Aug 2025 12:01:20 +0200 Subject: [PATCH 139/279] Tests: Enable coverage for script_tests Bug: T401124 Change-Id: Ib83f0d05ee92bf4fababc0d4f04b16362d585935 --- .github/workflows/pywikibot-ci.yml | 1 + tests/utils.py | 40 +++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 114fb7b98c..dea449927b 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -140,6 +140,7 @@ jobs: fi - name: Show coverage statistics run: | + coverage combine coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/tests/utils.py b/tests/utils.py index 8f5d342dba..30a9959224 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -9,9 +9,11 @@ import inspect import os import sys +import tempfile import unittest import warnings from contextlib import contextmanager, suppress +from pathlib import Path from subprocess import PIPE, Popen, TimeoutExpired from typing import Any, NoReturn @@ -521,16 +523,36 @@ def execute_pwb(args: list[str], *, """ command = [sys.executable] - if overrides: - command.append('-c') - overrides = '; '.join( - f'{key} = {value}' for key, value in overrides.items()) - command.append( - f'import pwb; import pywikibot; {overrides}; pwb.main()') - else: - command.append(_pwb_py) + # Test running and coverage is installed, enable coverage with subprocess + if os.environ.get('PYWIKIBOT_TEST_RUNNING') == '1': + with suppress(ModuleNotFoundError): + import coverage # noqa: F401 + command.extend(['-m', 'coverage', 'run', '--parallel-mode']) - return execute(command=command + args, data_in=data_in, timeout=timeout) + tmp_path: Path | None = None + try: + if overrides: + # Write overrides in temporary file + with tempfile.NamedTemporaryFile( + 'w', suffix='.py', delete=False) as f: + f.write('import pwb\nimport pywikibot\n') + f.write('\n'.join(f'{k} = {v}' for k, v in overrides.items())) + f.write('\npwb.main()\n') + tmp_path = Path(f.name) + command.append(f.name) + else: + command.append(_pwb_py) + + # Run subprocess + result = execute( + command=command + args, data_in=data_in, timeout=timeout) + + finally: + # delete temporary file if created + if tmp_path and tmp_path.exists(): + tmp_path.unlink() + + return result @contextmanager From d5d7e79dc9fcc6f6a8a195df07c5146eae08081f Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 22 Aug 2025 14:08:47 +0200 Subject: [PATCH 140/279] Tests: Add coverage combine to workflows Also add `|| true` to the coverage combine command, preventing job failures when there are no coverage files to combine. Bug: T401124 Change-Id: If5655b1b8d67780454e76663f6a4f43b1947928c --- .github/workflows/doctest.yml | 1 + .github/workflows/login_tests-ci.yml | 1 + .github/workflows/oauth_tests-ci.yml | 1 + .github/workflows/pywikibot-ci.yml | 2 +- .github/workflows/sysop_write_tests-ci.yml | 1 + .github/workflows/windows_tests.yml | 1 + 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/doctest.yml b/.github/workflows/doctest.yml index dee414e153..eb55cb64a2 100644 --- a/.github/workflows/doctest.yml +++ b/.github/workflows/doctest.yml @@ -68,6 +68,7 @@ jobs: coverage run -m pytest pywikibot --doctest-modules --ignore-glob="*gui.py" --ignore-glob="*memento.py" - name: Show coverage statistics run: | + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index 84e3f44fe1..cd7023306a 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -131,6 +131,7 @@ jobs: coverage run -m unittest -vv tests/site_login_logout_tests.py - name: Show coverage statistics run: | + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 0aa7e33c7b..0d8228ccae 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -102,6 +102,7 @@ jobs: coverage run -m unittest -vv - name: Show coverage statistics run: | + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index dea449927b..46f51d8f2a 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -140,7 +140,7 @@ jobs: fi - name: Show coverage statistics run: | - coverage combine + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/sysop_write_tests-ci.yml b/.github/workflows/sysop_write_tests-ci.yml index a08b124a08..b7a1b7df16 100644 --- a/.github/workflows/sysop_write_tests-ci.yml +++ b/.github/workflows/sysop_write_tests-ci.yml @@ -62,6 +62,7 @@ jobs: coverage run -m pytest -s -r A -a "${{ matrix.attr }}" - name: Show coverage statistics run: | + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/windows_tests.yml b/.github/workflows/windows_tests.yml index 13a24057f5..95c8aed240 100644 --- a/.github/workflows/windows_tests.yml +++ b/.github/workflows/windows_tests.yml @@ -75,6 +75,7 @@ jobs: coverage run -m unittest discover -vv -p \"*_tests.py\"; - name: Show coverage statistics run: | + coverage combine || true coverage report - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 From 781dd400c5db0bb036684f79d09dc8e896868129 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 22 Aug 2025 15:46:39 +0200 Subject: [PATCH 141/279] Tests: No longer import script within script_tests Scripts are already covers by subprocess. Also update coverage settings Change-Id: Ic910d7a85d8c142000707a5d855a169e62f25f84 --- pyproject.toml | 7 +++++-- tests/script_tests.py | 13 ------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ad901e9ba6..bbca9ca25b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,7 +136,7 @@ exclude_also = [ "@deprecated\\([^\\)]+\\)", "@unittest\\.skip", "class .+\\bProtocol\\):", - "except ImportError", + "except (ImportError|ModuleNotFoundError)", "except KeyboardInterrupt", "except OSError", "except SyntaxError", @@ -149,12 +149,15 @@ exclude_also = [ "if self\\.mw_version < .+:", # Comments to turn coverage on and off: "no cover: start(?s:.)*?no cover: stop", - "raise ImportError", + "raise (ImportError|ModuleNotFoundError)", "raise NotImplementedError", "raise unittest\\.SkipTest", "self\\.skipTest", ] +[tool.coverage.run] +concurrency = "multiprocessing" +parallel = true [tool.docsig] disable = [ diff --git a/tests/script_tests.py b/tests/script_tests.py index 77567a30da..4ff8fd2518 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -11,7 +11,6 @@ import sys import unittest from contextlib import suppress -from importlib import import_module from pathlib import Path from pywikibot.tools import has_module @@ -183,17 +182,6 @@ def load_tests(loader=unittest.loader.defaultTestLoader, return collector(loader) -def import_script(script_name: str) -> None: - """Import script for coverage only (T305795).""" - if not ci_test_run: - return # pragma: no cover - - prefix = 'scripts.' - if script_name in framework_scripts: - prefix = 'pywikibot.' + prefix - import_module(prefix + script_name) - - class ScriptTestMeta(MetaTestCaseClass): """Test meta class.""" @@ -300,7 +288,6 @@ def test_script(self) -> None: arguments = dct['_arguments'] for script_name in script_list: - import_script(script_name) # force login to be the first, alphabetically, so the login # message does not unexpectedly occur during execution of From a4cbfd9fc40776497783161421a74a60c8b91c3b Mon Sep 17 00:00:00 2001 From: Xqt Date: Fri, 22 Aug 2025 14:08:00 +0000 Subject: [PATCH 142/279] Test: Fix coverage settings Change-Id: I0779a17db498beb1c2c256a1bb443bd16e2df36c Signed-off-by: Xqt --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bbca9ca25b..890995f540 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,7 +156,7 @@ exclude_also = [ ] [tool.coverage.run] -concurrency = "multiprocessing" +concurrency = ["multiprocessing", "thread"] parallel = true [tool.docsig] From 20467a4741abc1d24975b8d85acda88e2e956a43 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 22 Aug 2025 16:15:23 +0200 Subject: [PATCH 143/279] Tests: Test Python 3.14 on Windows environment Change-Id: I1c7d104b39ff79efcbfcc23075896d508b5ce7bb --- .github/workflows/windows_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows_tests.yml b/.github/workflows/windows_tests.yml index 95c8aed240..34c3a5b4be 100644 --- a/.github/workflows/windows_tests.yml +++ b/.github/workflows/windows_tests.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8.0, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: [3.8.0, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 3.14-dev] python-arch: [x64, x86] site: ['wikipedia:en'] steps: From 39d5ef2270c8a498d30ffb7f7185f4f727dcbd3b Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 23 Aug 2025 11:39:55 +0200 Subject: [PATCH 144/279] Tests: use temporary file for coverage with githun actions only Change-Id: I6314ec03f4b299bfee137affefaf264de34501fe --- tests/utils.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 30a9959224..3f57ba1bde 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -517,36 +517,46 @@ def execute_pwb(args: list[str], *, the *error* parameter was removed. .. versionchanged:: 9.1 parameters except *args* are keyword only. + .. versionchanged:: 10.4 + coverage is used if running github actions and a temporary file + is used for overrides. :param args: list of arguments for pwb.py :param overrides: mapping of pywikibot symbols to test replacements """ + tmp_path: Path | None = None command = [sys.executable] + use_coverage = os.environ.get('GITHUB_ACTIONS') == '1' - # Test running and coverage is installed, enable coverage with subprocess - if os.environ.get('PYWIKIBOT_TEST_RUNNING') == '1': + if use_coverage: + # Test running and coverage is installed, + # enable coverage with subprocess with suppress(ModuleNotFoundError): import coverage # noqa: F401 command.extend(['-m', 'coverage', 'run', '--parallel-mode']) - tmp_path: Path | None = None - try: - if overrides: + if overrides: + override_code = 'import pwb, pywikibot\n' + override_code += '\n'.join(f'{k} = {v}' for k, v in overrides.items()) + override_code += '\npwb.main()' + + if use_coverage: # Write overrides in temporary file with tempfile.NamedTemporaryFile( 'w', suffix='.py', delete=False) as f: - f.write('import pwb\nimport pywikibot\n') - f.write('\n'.join(f'{k} = {v}' for k, v in overrides.items())) - f.write('\npwb.main()\n') + f.write(override_code) tmp_path = Path(f.name) - command.append(f.name) + command.append(f.name) else: - command.append(_pwb_py) + command.extend(['-c', override_code]) + else: + command.append(_pwb_py) + + try: # Run subprocess result = execute( command=command + args, data_in=data_in, timeout=timeout) - finally: # delete temporary file if created if tmp_path and tmp_path.exists(): From eac3a31576e0f5b35e554a39c321c02a05c47634 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 23 Aug 2025 12:11:45 +0200 Subject: [PATCH 145/279] coverage: update coverage settings - remove Users which was used by Appveyor - remove some scripts after script tests are covered now - remove "pragma: no cover" directive from several pywikibot scripts; they should be adjusted after script tests are covered now Change-Id: Ia153cf771b3086ea8bece36edab0f4fbb93c5c32 --- .codecov.yml | 4 ---- pywikibot/scripts/generate_family_file.py | 14 ++++++------ pywikibot/scripts/generate_user_files.py | 8 +++---- pywikibot/scripts/shell.py | 2 +- pywikibot/scripts/wrapper.py | 27 ++++++++++++----------- 5 files changed, 26 insertions(+), 29 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 9d56b91f84..71869ffe4f 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -19,13 +19,9 @@ coverage: default: enabled: true ignore: - - Users - pywikibot/daemonize.py - pywikibot/families/__init__.py - - pywikibot/scripts/version.py - scripts/create_isbn_edition.py - - scripts/maintenance/colors.py - - scripts/maintenance/make_i18n_dict.py - scripts/userscripts/ - tests/pwb/ notify: diff --git a/pywikibot/scripts/generate_family_file.py b/pywikibot/scripts/generate_family_file.py index f09046d2f4..1c382130fe 100755 --- a/pywikibot/scripts/generate_family_file.py +++ b/pywikibot/scripts/generate_family_file.py @@ -102,7 +102,7 @@ def show(*args, **kwargs): """Wrapper around print to be mocked in tests.""" print(*args, **kwargs) - def get_params(self) -> bool: # pragma: no cover + def get_params(self) -> bool: """Ask for parameters if necessary.""" if self.base_url is None: with suppress(KeyboardInterrupt): @@ -147,7 +147,7 @@ def get_wiki(self): break else: return w, verify - return None, None # pragma: no cover + return None, None def run(self) -> None: """Main method, generate family file.""" @@ -182,7 +182,7 @@ def getlangs(self, w) -> None: self.langs = w.langs self.show(fill(' '.join(sorted(wiki['prefix'] for wiki in self.langs)))) - except Exception as e: # pragma: no cover + except Exception as e: self.langs = [] self.show(e, '; continuing...') @@ -197,7 +197,7 @@ def getlangs(self, w) -> None: code_len = len(self.langs) if code_len > 1: if self.dointerwiki is None: - while True: # pragma: no cover + while True: makeiw = input( '\n' f'There are {code_len} sites available.' @@ -225,7 +225,7 @@ def getlangs(self, w) -> None: self.langs = [wiki for wiki in self.langs if domain in wiki['url']] - elif makeiw == 'e': # pragma: no cover + elif makeiw == 'e': for wiki in self.langs: self.show(wiki['prefix'], wiki['url']) do_langs = re.split(' *,| +', @@ -251,7 +251,7 @@ def getapis(self) -> None: try: self.wikis[key] = self.Wiki(lang['url']) self.show('downloaded') - except Exception as e: # pragma: no cover + except Exception as e: self.show(e) remove.append(lang) else: @@ -260,7 +260,7 @@ def getapis(self) -> None: for lang in remove: self.langs.remove(lang) - def writefile(self, verify) -> None: # pragma: no cover + def writefile(self, verify) -> None: """Write the family file.""" fp = Path(self.base_dir, 'families', f'{self.name}_family.py') self.show(f'Writing {fp}... ') diff --git a/pywikibot/scripts/generate_user_files.py b/pywikibot/scripts/generate_user_files.py index 9d96aef0b7..13957680f8 100755 --- a/pywikibot/scripts/generate_user_files.py +++ b/pywikibot/scripts/generate_user_files.py @@ -311,7 +311,7 @@ def input_sections(variant: str, select = pywikibot.input_choice( f'Do you want to select {variant} setting sections?', answers, default=default, force=force, automatic_quit=False) - if select == 'h': # pragma: no cover + if select == 'h': answers.pop(-1) pywikibot.info( f'The following {variant} setting sections are provided:') @@ -324,7 +324,7 @@ def input_sections(variant: str, choice = {'a': 'all', 'n': 'none', 'y': 'h'}[select] # mapping for item in filter(skip, sections): answers = [('Yes', 'y'), ('No', 'n'), ('Help', 'h')] - while choice == 'h': # pragma: no cover + while choice == 'h': choice = pywikibot.input_choice( f'Do you want to add {item.head} section?', answers, default='n', force=force, automatic_quit=False) @@ -350,7 +350,7 @@ def create_user_config( main_code: str, main_username: str, force: bool = False -) -> None: # pragma: no cover +) -> None: """Create a user-config.py in base_dir. Create a user-password.py if necessary. @@ -440,7 +440,7 @@ def create_user_config( def save_botpasswords(botpasswords: str, - path: Path) -> None: # pragma: no cover + path: Path) -> None: """Write botpasswords to file. :param botpasswords: botpasswords for password file diff --git a/pywikibot/scripts/shell.py b/pywikibot/scripts/shell.py index b5e8331cf3..487bcab666 100755 --- a/pywikibot/scripts/shell.py +++ b/pywikibot/scripts/shell.py @@ -26,7 +26,7 @@ import sys -def main(*args: str) -> None: # pragma: no cover +def main(*args: str) -> None: """Script entry point. .. versionchanged:: 8.2 diff --git a/pywikibot/scripts/wrapper.py b/pywikibot/scripts/wrapper.py index d4b0081f7c..eff65f66a0 100755 --- a/pywikibot/scripts/wrapper.py +++ b/pywikibot/scripts/wrapper.py @@ -77,13 +77,13 @@ def check_pwb_versions(package: str) -> None: scripts_version = Version(getattr(package, '__version__', pwb.__version__)) wikibot_version = Version(pwb.__version__) - if scripts_version.release > wikibot_version.release: # pragma: no cover + if scripts_version.release > wikibot_version.release: print(f'WARNING: Pywikibot version {wikibot_version} is behind ' f'scripts package version {scripts_version}.\n' 'Your Pywikibot may need an update or be misconfigured.\n') # calculate previous minor release - if wikibot_version.minor > 0: # pragma: no cover + if wikibot_version.minor > 0: prev_wikibot = Version( f'{wikibot_version.major}.{wikibot_version.minor - 1}.' f'{wikibot_version.micro}' @@ -94,7 +94,7 @@ def check_pwb_versions(package: str) -> None: f'behind legacy Pywikibot version {prev_wikibot} and ' f'current version {wikibot_version}\n' 'Your scripts may need an update or be misconfigured.\n') - elif scripts_version.release < wikibot_version.release: # pragma: no cover + elif scripts_version.release < wikibot_version.release: print(f'WARNING: Scripts package version {scripts_version} is behind ' f'current version {wikibot_version}\n' 'Your scripts may need an update or be misconfigured.\n') @@ -141,7 +141,7 @@ def run_python_file(filename: str, args: list[str], package=None) -> None: # set environment values old_env = os.environ.copy() - for key, value in environ: # pragma: no cover + for key, value in environ: os.environ[key] = value sys.argv = [filename, *args] @@ -165,7 +165,7 @@ def run_python_file(filename: str, args: list[str], package=None) -> None: # end of snippet from coverage # Restore environment values - for key, value in environ: # pragma: no cover + for key, value in environ: if key in old_env: os.environ[key] = old_env[key] else: @@ -210,7 +210,7 @@ def handle_args( def _print_requirements(requirements, script, - variant) -> None: # pragma: no cover + variant) -> None: """Print pip command to install requirements.""" if not requirements: return @@ -341,7 +341,7 @@ def find_alternates(filename, script_paths): scripts = {} for folder in script_paths: - if not folder.exists(): # pragma: no cover + if not folder.exists(): warning( f'{folder} does not exists; remove it from user_script_paths') continue @@ -376,7 +376,7 @@ def find_alternates(filename, script_paths): alternatives, default='1') except QuitKeyboardInterrupt: return None - print() # pragma: no cover + print() return str(scripts[script]) @@ -406,7 +406,7 @@ def test_paths(paths, root: Path): # search through user scripts paths user_script_paths = [''] - if config.user_script_paths: # pragma: no cover + if config.user_script_paths: if isinstance(config.user_script_paths, list): user_script_paths += config.user_script_paths else: @@ -415,10 +415,11 @@ def test_paths(paths, root: Path): ' Ignoring this setting.', stacklevel=2) found = test_paths(user_script_paths, Path(config.base_dir)) - if found: # pragma: no cover + if found: return found - if site_package: # search for entry points + if site_package: # pragma: no cover + # search for entry points import importlib from importlib.metadata import entry_points @@ -477,7 +478,7 @@ def execute() -> bool: if global_args: # don't use sys.argv unknown_args = pwb.handle_args(global_args) - if unknown_args: # pragma: no cover + if unknown_args: print('ERROR: unknown pwb.py argument{}: {}\n' .format('' if len(unknown_args) == 1 else 's', ', '.join(unknown_args))) @@ -535,7 +536,7 @@ def main() -> None: .. versionchanged:: 7.0 previous implementation was renamed to :func:`execute` """ - if not check_modules(): # pragma: no cover + if not check_modules(): sys.exit() if not execute(): From c96acfd1a7baf75cf0b8f83594bba8760a56e6f4 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 23 Aug 2025 17:31:17 +0200 Subject: [PATCH 146/279] Update git submodules * Update scripts/i18n from branch 'master' to e823b23a07ae6077db5cff30751906f0fe9ba7e0 - i18n: Remove 'category_redirect-log-move-error' translations from Pywikibot The 'category_redirect-log-move-error' translation was removed in rPWBC576a51bd1cf5 with release 7.0 and is no longer needed. Bug: T300429 Change-Id: Iee521d91470619e08fc1b735db73fbb9fce32fe7 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index e97d7a85ec..e823b23a07 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit e97d7a85ec984a193d43292c00e5544e56180eeb +Subproject commit e823b23a07ae6077db5cff30751906f0fe9ba7e0 From 73d3b507c4114804cce048e02143cf2edb4e7572 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 16 Aug 2025 19:52:16 +0200 Subject: [PATCH 147/279] [fixes] Improve parameter_help formatting and _load_file readability - parameter_help: improve wording for CLI help output - _load_file: use pathlib for modern path handling and clarify docstring Change-Id: I2ecc278de04339961ecc6a8f851f45be1e6d1ba7 --- pywikibot/fixes.py | 54 +++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/pywikibot/fixes.py b/pywikibot/fixes.py index db65059151..684850c03e 100644 --- a/pywikibot/fixes.py +++ b/pywikibot/fixes.py @@ -1,18 +1,18 @@ """File containing all standard fixes.""" # -# (C) Pywikibot team, 2008-2022 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # from __future__ import annotations -import os.path +from pathlib import Path from pywikibot import config parameter_help = """ - Currently available predefined fixes are: + Currently available predefined fixes: * HTML - Convert HTML tags to wiki syntax, and fix XHTML. @@ -20,21 +20,20 @@ * syntax - Try to fix bad wiki markup. Do not run this in automatic mode, as the bot may make mistakes. - * syntax-safe - Like syntax, but less risky, so you can - run this in automatic mode. - * case-de - fix upper/lower case errors in German - * grammar-de - fix grammar and typography in German - * vonbis - Ersetze Binde-/Gedankenstrich durch "bis" - in German - * music - Links auf Begriffsklärungen in German - * datum - specific date formats in German - * correct-ar - Typo corrections for Arabic Wikipedia and any - Arabic wiki. - * yu-tld - Fix links to .yu domains because it is - disabled, see: + * syntax-safe - Like syntax, but less risky; can be run + in automatic mode. + * case-de - Fix upper/lower case errors in German. + * grammar-de - Fix grammar and typography in German. + * vonbis - Replace hyphens or dashes with "bis" + in German. + * music - Links to disambiguation pages in German. + * datum - Specific date formats in German. + * correct-ar - Typo corrections for Arabic Wikipedia + and other Arabic wikis. + * yu-tld - Fix links to .yu domains, which are disabled. + See: https://lists.wikimedia.org/pipermail/wikibots-l/2009-February/000290.html - * fckeditor - Try to convert FCKeditor HTML tags to wiki - syntax. + * fckeditor - Convert FCKeditor HTML tags to wiki syntax. """ __doc__ += parameter_help @@ -673,20 +672,27 @@ 'msg': 'pywikibot-fixes-fckeditor', 'replacements': [ # replace
with a new line - (r'(?i)
', r'\n'), + (r'(?i)
', r'\n'), # replace   with a space - (r'(?i) ', r' '), + (r'(?i) ', r' '), ], }, } def _load_file(filename: str) -> bool: - """Load the fixes from the given filename.""" - if os.path.exists(filename): - # load binary, to let compile decode it according to the file header - with open(filename, 'rb') as f: - exec(compile(f.read(), filename, 'exec'), globals()) + """Load the fixes from the given filename. + + Returns True if the file existed and was loaded, False otherwise. + + :meta public: + """ + path = Path(filename) + if path.exists(): + # Read file as binary, so that compile can detect encoding from header + with path.open('rb') as f: + code = compile(f.read(), filename, 'exec') + exec(code, globals()) # intentionally in globals return True return False From 718c29940f9226ed0829218031888927f4479cdd Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 17 Aug 2025 16:28:20 +0200 Subject: [PATCH 148/279] page._collections: Improve repr string for BaseDataDict and ClaimCollection - use __class__.__name__ for class identifier - use reprlib.repr to shorten long data repr strings - add tests for repr and str functions Change-Id: I32e7909a257e0864f4bd63c57af3e2de98f28342 --- pywikibot/page/_collections.py | 7 ++++--- tests/wikibase_tests.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/pywikibot/page/_collections.py b/pywikibot/page/_collections.py index 0bcc05c3fd..b3b79843bc 100644 --- a/pywikibot/page/_collections.py +++ b/pywikibot/page/_collections.py @@ -1,11 +1,12 @@ """Structures holding data for Wikibase entities.""" # -# (C) Pywikibot team, 2019-2024 +# (C) Pywikibot team, 2019-2025 # # Distributed under the terms of the MIT license. # from __future__ import annotations +import reprlib from collections import defaultdict from collections.abc import MutableMapping, MutableSequence from typing import Any @@ -65,7 +66,7 @@ def __contains__(self, key) -> bool: return key in self._data def __repr__(self) -> str: - return f'{type(self)}({self._data})' + return f'{type(self).__name__}({reprlib.repr(self._data)})' @staticmethod def normalizeKey(key) -> str: @@ -241,7 +242,7 @@ def __contains__(self, key) -> bool: return key in self._data def __repr__(self) -> str: - return f'{type(self)}({self._data})' + return f'{type(self).__name__}({reprlib.repr(self._data)})' @classmethod def normalizeData(cls, data) -> dict: diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py index 14ef333c7a..89592bd14c 100755 --- a/tests/wikibase_tests.py +++ b/tests/wikibase_tests.py @@ -324,8 +324,12 @@ def test_empty_item(self) -> None: item = ItemPage(wikidata) self.assertEqual(item._link._title, '-1') self.assertLength(item.labels, 0) + self.assertEqual(str(item.labels), 'LanguageDict({})') + self.assertEqual(repr(item.labels), 'LanguageDict({})') self.assertLength(item.descriptions, 0) self.assertLength(item.aliases, 0) + self.assertEqual(str(item.aliases), 'AliasesDict({})') + self.assertEqual(repr(item.aliases), 'AliasesDict({})') self.assertLength(item.claims, 0) self.assertLength(item.sitelinks, 0) @@ -1453,6 +1457,23 @@ def test_base_data(self) -> None: self.assertIn('en', item.aliases) self.assertIn('NYC', item.aliases['en']) + def test_str_repr(self) -> None: + """Test str and repr of labels and aliases.""" + self.assertEqual( + str(self.wdp.labels), + "LanguageDict({'af': 'New York Stad', 'als': 'New York City', " + "'am': 'ኒው ዮርክ ከተማ', 'an': 'Nueva York', ...})" + ) + self.assertEqual( + str(self.wdp.aliases), + "AliasesDict({'be': ['Горад Нью-Ёрк'], 'be-tarask': ['Нью Ёрк'], " + "'ca': ['Ciutat de Nova York', 'New York City'," + " 'New York City (New York)', 'NYC', 'N. Y.', 'N Y'], " + "'da': ['New York City'], ...})" + ) + self.assertEqual(str(self.wdp.labels), repr(self.wdp.labels)) + self.assertEqual(str(self.wdp.aliases), repr(self.wdp.aliases)) + def test_itempage_json(self) -> None: """Test itempage json.""" old = json.dumps(self.wdp._content, indent=2, sort_keys=True) From e232efc122547980fae0c3fa3d303ee74d421e6d Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 23 Aug 2025 17:14:20 +0200 Subject: [PATCH 149/279] i18n: Make doctests for known_languages() more robust - Skip unstable slice of last 10 languages with +SKIP - Reduce first slice to a stable 2 entries - Use membership tests and flexible length check instead of fixed numbers Change-Id: I06f1f275aa132cabe61f30596f1126131092ce26 --- pywikibot/i18n.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/pywikibot/i18n.py b/pywikibot/i18n.py index ef32722b4c..685e918413 100644 --- a/pywikibot/i18n.py +++ b/pywikibot/i18n.py @@ -913,15 +913,19 @@ def bundles(stem: bool = False) -> Generator[Path | str, None, None]: def known_languages() -> list[str]: - """All languages we have localizations for. + """Return all languages we have localizations for. >>> from pywikibot import i18n - >>> i18n.known_languages()[:10] - ['ab', 'aeb', 'af', 'am', 'an', 'ang', 'anp', 'ar', 'arc', 'arz'] - >>> i18n.known_languages()[-10:] + >>> i18n.known_languages()[:2] + ['ab', 'aeb'] + >>> i18n.known_languages()[-10:] # doctest: +SKIP ['vo', 'vro', 'wa', 'war', 'xal', 'xmf', 'yi', 'yo', 'yue', 'zh'] - >>> len(i18n.known_languages()) - 251 + >>> len(i18n.known_languages()) > 250 + True + >>> 'ab' in i18n.known_languages() + True + >>> 'zh' in i18n.known_languages() + True The implementation is roughly equivalent to: From 61fd3318828ceb82725ab95d9f7b5235860ab02e Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 23 Aug 2025 18:27:40 +0200 Subject: [PATCH 150/279] Update plural forms from unicode.org https://www.unicode.org/cldr/charts/47/supplemental/language_plural_rules.html Bug: T114978 Change-Id: I19fdde76540b4ddc72a09b5f91139ab463d7b731 --- pywikibot/plural.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pywikibot/plural.py b/pywikibot/plural.py index df526d52fd..8fb0e54f2c 100644 --- a/pywikibot/plural.py +++ b/pywikibot/plural.py @@ -1,6 +1,6 @@ """Module containing plural rules of various languages.""" # -# (C) Pywikibot team, 2011-2022 +# (C) Pywikibot team, 2011-2025 # # Distributed under the terms of the MIT license. # @@ -62,11 +62,12 @@ 0 if (n == 0) else 1 if n == 1 else 2}, - 'mt': {'nplurals': 4, 'plural': lambda n: - 0 if (n == 1) else - 1 if (n == 0 or (1 < (n % 100) < 11)) else - 2 if (10 < (n % 100) < 20) else - 3}, + 'mt': {'nplurals': 5, 'plural': lambda n: + 0 if n == 1 else + 1 if n == 2 else + 2 if n == 0 or 3 <= (n % 100) <= 10 else + 3 if 11 <= (n % 100) <= 19 else + 4}, 'pl': {'nplurals': 3, 'plural': lambda n: 0 if (n == 1) else 1 if (2 <= (n % 10) <= 4) and (n % 100 < 10 or n % 100 >= 20) From 269d30cb81ccfe5816495ab11ec05859151c0cc7 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 24 Aug 2025 12:54:00 +0200 Subject: [PATCH 151/279] [tests] Speedup script_tests Do not create a test method if script is in _allowed_failures; such scripts may or may not fail and it does not make any sense to create the test method and use a @skip decorator to skip it. Change-Id: I67ef4433513b4d51756517fe3006fc04529ac7c3 --- tests/script_tests.py | 65 ++++++++++++++++++++----------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/tests/script_tests.py b/tests/script_tests.py index 4ff8fd2518..d1f635b495 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -13,6 +13,7 @@ from contextlib import suppress from pathlib import Path +from pywikibot.backports import Iterator from pywikibot.tools import has_module from tests import join_root_path, unittest_print from tests.aspects import DefaultSiteTestCase, MetaTestCaseClass, PwbTestCase @@ -148,40 +149,43 @@ def list_scripts(path: str, exclude: str = '') -> list[str]: } -def collector(loader=unittest.loader.defaultTestLoader): - """Load the default tests. - - .. note:: Raising SkipTest during load_tests will cause the loader - to fallback to its own discover() ordering of unit tests. - """ - if unrunnable_script_set: # pragma: no cover - unittest_print('Skipping execution of unrunnable scripts:\n' - f'{unrunnable_script_set!r}') - - test_pattern = 'tests.script_tests.TestScript{}.test_{}' +def collector() -> Iterator[str]: + """Generate test names in the correct order, respecting filters.""" + base_tests = ['_login'] + [ + name for name in sorted(script_list) + if name != 'login' + # Exclude scripts that cannot or should not run + and name not in unrunnable_script_set + # Exclude scripts that fail due to missing dependencies + and name not in failed_dep_script_set + ] - tests = ['_login'] + [name for name in sorted(script_list) - if name != 'login' - and name not in unrunnable_script_set] - test_list = [test_pattern.format('Help', name) for name in tests] + # Build filtered test lists per class + class_to_tests = { + TestScriptHelp: base_tests, + TestScriptSimulate: base_tests, + TestScriptGenerator: [ + name for name in base_tests if name not in auto_run_script_set + ] + } - tests = [name for name in tests if name not in failed_dep_script_set] - test_list += [test_pattern.format('Simulate', name) for name in tests] + # Yield fully qualified test names, skipping expected failures + for cls, names in class_to_tests.items(): + expected_failures = getattr(cls, '_expected_failures', set()) + for name in names: + if name not in expected_failures: + yield f'tests.script_tests.{cls.__name__}.test_{name}' - tests = [name for name in tests if name not in auto_run_script_set] - test_list += [test_pattern.format('Generator', name) for name in tests] +def load_tests(loader: unittest.TestLoader = unittest.defaultTestLoader, + standard_tests=None, + pattern=None) -> unittest.TestSuite: + """Load the default modules and return a TestSuite.""" suite = unittest.TestSuite() - suite.addTests(loader.loadTestsFromNames(test_list)) + suite.addTests(loader.loadTestsFromNames(collector())) return suite -def load_tests(loader=unittest.loader.defaultTestLoader, - tests=None, pattern=None): - """Load the default modules.""" - return collector(loader) - - class ScriptTestMeta(MetaTestCaseClass): """Test meta class.""" @@ -305,15 +309,6 @@ def test_script(self) -> None: if script_name in dct['_expected_failures']: dct[test_name] = unittest.expectedFailure(dct[test_name]) - elif script_name in dct['_allowed_failures']: - dct[test_name] = unittest.skip( - f'{script_name} is in _allowed_failures set' - )(dct[test_name]) - elif script_name in failed_dep_script_set \ - and arguments == '-simulate': - dct[test_name] = unittest.skip( - f'{script_name} has dependencies; skipping' - )(dct[test_name]) return super().__new__(cls, name, bases, dct) From 1390accfa890d31acf1bb86927275546883ef5f7 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 24 Aug 2025 17:29:32 +0200 Subject: [PATCH 152/279] tests: refactor script test loading and filtering This patch synchronizes the script tests of `TestClass` and `TestSuite`. - Introduce `filter_scripts()` to centralize script selection and remove redundant filtering logic. - Replace previous collector logic with a simple generator using `_script_names` from each test class. - Remove metaclass reliance on hard-coded script lists; classes now get `_script_list` populated via `filter_scripts()`. Change-Id: Ie95ff04c7a7ed91da193aff2a043c1c54a2da7f8 --- tests/script_tests.py | 79 ++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/tests/script_tests.py b/tests/script_tests.py index d1f635b495..bc2b346d25 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -150,42 +150,45 @@ def list_scripts(path: str, exclude: str = '') -> list[str]: def collector() -> Iterator[str]: - """Generate test names in the correct order, respecting filters.""" - base_tests = ['_login'] + [ - name for name in sorted(script_list) - if name != 'login' - # Exclude scripts that cannot or should not run - and name not in unrunnable_script_set - # Exclude scripts that fail due to missing dependencies - and name not in failed_dep_script_set - ] - - # Build filtered test lists per class - class_to_tests = { - TestScriptHelp: base_tests, - TestScriptSimulate: base_tests, - TestScriptGenerator: [ - name for name in base_tests if name not in auto_run_script_set - ] - } - - # Yield fully qualified test names, skipping expected failures - for cls, names in class_to_tests.items(): - expected_failures = getattr(cls, '_expected_failures', set()) - for name in names: - if name not in expected_failures: - yield f'tests.script_tests.{cls.__name__}.test_{name}' + """Generate test fully qualified names from test classes.""" + for cls in TestScriptHelp, TestScriptSimulate, TestScriptGenerator: + for name in cls._script_list: + name = '_' + name if name == 'login' else name + yield f'tests.script_tests.{cls.__name__}.test_{name}' def load_tests(loader: unittest.TestLoader = unittest.defaultTestLoader, - standard_tests=None, - pattern=None) -> unittest.TestSuite: + standard_tests: unittest.TestSuite | None = None, + pattern: str | None = None) -> unittest.TestSuite: """Load the default modules and return a TestSuite.""" suite = unittest.TestSuite() suite.addTests(loader.loadTestsFromNames(collector())) return suite +def filter_scripts(excluded: set[str] | None = None, *, + exclude_auto_run: bool = False) -> list[str]: + """Return a filtered list of script names. + + :param excluded: Scripts to exclude explicitly. + :param exclude_auto_run: If True, remove scripts in auto_run_script_set. + :return: A list of valid script names in deterministic order. + """ + excluded = excluded or set() + + scripts = ['login'] + [ + name for name in sorted(script_list) + if name != 'login' + and name not in unrunnable_script_set + and name not in failed_dep_script_set + ] + + if exclude_auto_run: + scripts = [n for n in scripts if n not in auto_run_script_set] + + return [n for n in scripts if n not in excluded] + + class ScriptTestMeta(MetaTestCaseClass): """Test meta class.""" @@ -291,24 +294,19 @@ def test_script(self) -> None: arguments = dct['_arguments'] - for script_name in script_list: + for script in dct['_script_list']: # force login to be the first, alphabetically, so the login # message does not unexpectedly occur during execution of # another script. - # unrunnable script tests are disabled by default in load_tests() - - if script_name == 'login': - test_name = 'test__login' - else: - test_name = 'test_' + script_name + test = 'test__login' if script == 'login' else 'test_' + script - cls.add_method(dct, test_name, - test_execution(script_name, arguments.split()), - f'Test running {script_name} {arguments}.') + cls.add_method(dct, test, + test_execution(script, arguments.split()), + f'Test running {script} {arguments}.') - if script_name in dct['_expected_failures']: - dct[test_name] = unittest.expectedFailure(dct[test_name]) + if script in dct['_expected_failures']: + dct[test] = unittest.expectedFailure(dct[test]) return super().__new__(cls, name, bases, dct) @@ -331,6 +329,7 @@ class TestScriptHelp(PwbTestCase, metaclass=ScriptTestMeta): _results = None _skip_results = {} _timeout = False + _script_list = filter_scripts() class TestScriptSimulate(DefaultSiteTestCase, PwbTestCase, @@ -379,6 +378,7 @@ class TestScriptSimulate(DefaultSiteTestCase, PwbTestCase, _results = no_args_expected_results _skip_results = skip_on_results _timeout = auto_run_script_set + _script_list = filter_scripts(_allowed_failures) class TestScriptGenerator(DefaultSiteTestCase, PwbTestCase, @@ -446,6 +446,7 @@ class TestScriptGenerator(DefaultSiteTestCase, PwbTestCase, _results = ("Working on 'Foobar'", 'Script terminated successfully') _skip_results = {} _timeout = True + _script_list = filter_scripts(_allowed_failures, exclude_auto_run=True) if __name__ == '__main__': From a4fef01c480ae3b87eeb86e3c3a62b1d27e97922 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 24 Aug 2025 18:13:11 +0200 Subject: [PATCH 153/279] tests: do not exclude failed dependencies for TestScriptHelp Bug: T276466 Change-Id: Iaf4dbe26da52555e5f5b7460e4aa6746b7602e2f --- tests/script_tests.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/script_tests.py b/tests/script_tests.py index bc2b346d25..4827742c80 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -38,8 +38,6 @@ def check_script_deps(script_name) -> bool: if script_name in script_deps: for package_name in script_deps[script_name]: if not has_module(package_name): - unittest_print(f'{script_name} depends on {package_name},' - " which isn't available") return False return True @@ -167,11 +165,15 @@ def load_tests(loader: unittest.TestLoader = unittest.defaultTestLoader, def filter_scripts(excluded: set[str] | None = None, *, - exclude_auto_run: bool = False) -> list[str]: + exclude_auto_run: bool = False, + exclude_failed_dep: bool = True) -> list[str]: """Return a filtered list of script names. :param excluded: Scripts to exclude explicitly. - :param exclude_auto_run: If True, remove scripts in auto_run_script_set. + :param exclude_auto_run: If True, remove scripts in + auto_run_script_set. + :param exclude_failed_dep: If True, remove scripts in + failed_dep_script_set. :return: A list of valid script names in deterministic order. """ excluded = excluded or set() @@ -180,7 +182,7 @@ def filter_scripts(excluded: set[str] | None = None, *, name for name in sorted(script_list) if name != 'login' and name not in unrunnable_script_set - and name not in failed_dep_script_set + and (not exclude_failed_dep or name not in failed_dep_script_set) ] if exclude_auto_run: @@ -329,7 +331,7 @@ class TestScriptHelp(PwbTestCase, metaclass=ScriptTestMeta): _results = None _skip_results = {} _timeout = False - _script_list = filter_scripts() + _script_list = filter_scripts(exclude_failed_dep=False) class TestScriptSimulate(DefaultSiteTestCase, PwbTestCase, From 4a01adcb7ed8f9c0baba03db650efd6a965d52e9 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 24 Aug 2025 20:04:02 +0200 Subject: [PATCH 154/279] Tests: recover unittest.skip in ScriptTestMeta Skipping tests is required if tests are collected with default collector. Change-Id: I8c915e2ba74e77f3fc0ddc29bf67f35c6c8cf984 --- tests/script_tests.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/script_tests.py b/tests/script_tests.py index 4827742c80..6c0128cc8a 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -155,10 +155,15 @@ def collector() -> Iterator[str]: yield f'tests.script_tests.{cls.__name__}.test_{name}' +custom_loader = False + + def load_tests(loader: unittest.TestLoader = unittest.defaultTestLoader, standard_tests: unittest.TestSuite | None = None, pattern: str | None = None) -> unittest.TestSuite: """Load the default modules and return a TestSuite.""" + global custom_loader + custom_loader = True suite = unittest.TestSuite() suite.addTests(loader.loadTestsFromNames(collector())) return suite @@ -296,7 +301,11 @@ def test_script(self) -> None: arguments = dct['_arguments'] - for script in dct['_script_list']: + if custom_loader: + collected_scripts = dct['_script_list'] + else: + collected_scripts = filter_scripts(exclude_failed_dep=False) + for script in collected_scripts: # force login to be the first, alphabetically, so the login # message does not unexpectedly occur during execution of @@ -309,6 +318,14 @@ def test_script(self) -> None: if script in dct['_expected_failures']: dct[test] = unittest.expectedFailure(dct[test]) + elif script in dct['_allowed_failures']: + dct[test] = unittest.skip( + f'{script} is in _allowed_failures set' + )(dct[test]) + elif script in failed_dep_script_set and arguments == '-simulate': + dct[test] = unittest.skip( + f'{script} has dependencies; skipping' + )(dct[test]) return super().__new__(cls, name, bases, dct) From 9376906409612617dee5b2d4d91eea1d5f2e8cc2 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 24 Aug 2025 21:01:16 +0200 Subject: [PATCH 155/279] tests: enable global options in script_tests script_tests calls pwb with script and local args, but the given global options weren't taken. Now global arguments are saved in bot.global_args and reused within script_tests. Bug: T250034 Change-Id: Ibaf8ab0a58aa68417b4b0e6006ac0fd3ca8ac251 --- pywikibot/bot.py | 6 ++++++ tests/script_tests.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pywikibot/bot.py b/pywikibot/bot.py index 3ed061165f..9aab5c25b4 100644 --- a/pywikibot/bot.py +++ b/pywikibot/bot.py @@ -278,6 +278,9 @@ class is mainly used for bots which work with Wikibase or together """Holds a user interface object defined in :mod:`pywikibot.userinterfaces` subpackage.""" +#: global args used by tests via pwb wrapper +global_args: list[str] | None = None + def set_interface(module_name: str) -> None: """Configures any bots to use the given interface module. @@ -749,6 +752,9 @@ def handle_args(args: Iterable[str] | None = None, # not the one in pywikibot.bot. args = pywikibot.argvu[1:] + global global_args + global_args = args + # get the name of the module calling this function. This is # required because the -help option loads the module's docstring and # because the module name will be used for the filename of the log. diff --git a/tests/script_tests.py b/tests/script_tests.py index 6c0128cc8a..356c6df43e 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -14,6 +14,7 @@ from pathlib import Path from pywikibot.backports import Iterator +from pywikibot.bot import global_args as pwb_args from pywikibot.tools import has_module from tests import join_root_path, unittest_print from tests.aspects import DefaultSiteTestCase, MetaTestCaseClass, PwbTestCase @@ -212,7 +213,7 @@ def test_execution(script_name, args=None): def test_script(self) -> None: global_args_msg = \ 'For global options use -help:global or run pwb' - global_args = ['-pwb_close_matches:1'] + global_args = (pwb_args or []) + ['-pwb_close_matches:1'] cmd = [*global_args, script_name, *args] data_in = script_input.get(script_name) From eeec88f724ad2cd65bbb0788ebd5d2b7e3bd739d Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 24 Aug 2025 21:23:40 +0200 Subject: [PATCH 156/279] Tests: Update pre-commit hooks Change-Id: Iada6ef334e37e532ae1a1ae5f47978f92ef602f0 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3a2059e6d4..9bcdf70737 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.9 + rev: v0.12.10 hooks: - id: ruff-check alias: ruff From 438d48f5eded4769de7aee2a131cdc68ff36c888 Mon Sep 17 00:00:00 2001 From: Xqt Date: Sun, 24 Aug 2025 21:44:20 +0000 Subject: [PATCH 157/279] Tests: Fix GITHUB_ACTIONS check Change-Id: If483e6116772b384c94bb82c23ad2392c2c80673 Signed-off-by: Xqt --- tests/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils.py b/tests/utils.py index 3f57ba1bde..44d8227607 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -526,7 +526,7 @@ def execute_pwb(args: list[str], *, """ tmp_path: Path | None = None command = [sys.executable] - use_coverage = os.environ.get('GITHUB_ACTIONS') == '1' + use_coverage = os.environ.get('GITHUB_ACTIONS') if use_coverage: # Test running and coverage is installed, From 9e83b97f252f550d017855ad5f9bd081d73f64d3 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 25 Aug 2025 14:31:27 +0200 Subject: [PATCH 158/279] Update git submodules * Update scripts/i18n from branch 'master' to b725db5250449bf3a353a60e626af38cc87d9f6e - Localisation updates from https://translatewiki.net. Change-Id: I170951cbf7ad37b02460821d3a8320e58b804e82 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index e823b23a07..b725db5250 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit e823b23a07ae6077db5cff30751906f0fe9ba7e0 +Subproject commit b725db5250449bf3a353a60e626af38cc87d9f6e From 2831dbe9fb1a7ea75ff04e14382390cf408532c1 Mon Sep 17 00:00:00 2001 From: DerIch27 Date: Mon, 25 Aug 2025 13:39:32 +0000 Subject: [PATCH 159/279] add user-agent header to eventstream requests see https://foundation.wikimedia.org/wiki/Policy:Wikimedia_Foundation_User-Agent_Policy Bug: T402796 Change-Id: Ib581d3bcf493fd0d64393260dd57c6ad7b692299 --- pywikibot/comms/__init__.py | 2 +- pywikibot/comms/eventstreams.py | 5 +++++ tests/eventstreams_tests.py | 17 ++++++++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/pywikibot/comms/__init__.py b/pywikibot/comms/__init__.py index e76cb24e81..49a90272bc 100644 --- a/pywikibot/comms/__init__.py +++ b/pywikibot/comms/__init__.py @@ -1,6 +1,6 @@ """Communication layer.""" # -# (C) Pywikibot team, 2008-2022 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # diff --git a/pywikibot/comms/eventstreams.py b/pywikibot/comms/eventstreams.py index 3d800b0e99..81a32fe25c 100644 --- a/pywikibot/comms/eventstreams.py +++ b/pywikibot/comms/eventstreams.py @@ -27,6 +27,7 @@ from pywikibot import Site, Timestamp, config, debug, warning from pywikibot.backports import NoneType +from pywikibot.comms.http import user_agent from pywikibot.tools import cached, deprecated_args from pywikibot.tools.collections import GeneratorWrapper @@ -207,6 +208,10 @@ def __init__(self, **kwargs) -> None: kwargs['reconnection_time'] = timedelta(milliseconds=retry) kwargs.setdefault('timeout', config.socket_timeout) + + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('user-agent', user_agent(self._site)) + self.sse_kwargs = kwargs def __repr__(self) -> str: diff --git a/tests/eventstreams_tests.py b/tests/eventstreams_tests.py index 81c1ab2116..35d4a8c7d7 100755 --- a/tests/eventstreams_tests.py +++ b/tests/eventstreams_tests.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import re import unittest from contextlib import suppress from unittest import mock @@ -45,8 +46,11 @@ def test_url_parameter(self, key) -> None: self.assertEqual(e._url, e.sse_kwargs.get('url')) self.assertIsNone(e._total) self.assertIsNone(e._streams) - self.assertEqual(repr(e), - f"EventStreams(url='{self.sites[key]['hostname']}')") + self.assertRegex( + repr(e), + rf"^EventStreams\(url={self.sites[key]['hostname']!r}, " + r"headers={'user-agent': '[^']+'}\)$" + ) def test_url_from_site(self, key) -> None: """Test EventStreams with url from site.""" @@ -59,9 +63,12 @@ def test_url_from_site(self, key) -> None: self.assertEqual(e._url, e.sse_kwargs.get('url')) self.assertIsNone(e._total) self.assertEqual(e._streams, streams) - site_repr = f'site={site!r}, ' if site != Site() else '' - self.assertEqual(repr(e), - f"EventStreams({site_repr}streams='{streams}')") + site_repr = re.escape(f'site={site!r}, ') if site != Site() else '' + self.assertRegex( + repr(e), + r"^EventStreams\(headers={'user-agent': '[^']+'}, " + rf'{site_repr}streams={streams!r}\)$' + ) @mock.patch('pywikibot.comms.eventstreams.EventSource', new=mock.MagicMock()) From 6f50aac944d6f59f72f829ce17aa49fccb5071c2 Mon Sep 17 00:00:00 2001 From: Alexander Vorwerk Date: Wed, 27 Aug 2025 21:40:31 +0200 Subject: [PATCH 160/279] Add support for bewwiktionary Bug: T402136 Change-Id: Ifbd32b405148354ac43f6d3fb638635c4b693ea4 --- pywikibot/families/wiktionary_family.py | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/pywikibot/families/wiktionary_family.py b/pywikibot/families/wiktionary_family.py index aab65518ab..1f238a0cf5 100644 --- a/pywikibot/families/wiktionary_family.py +++ b/pywikibot/families/wiktionary_family.py @@ -34,22 +34,22 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): ] codes = { - 'af', 'am', 'an', 'ang', 'ar', 'ast', 'ay', 'az', 'bcl', 'be', 'bg', - 'bjn', 'blk', 'bn', 'br', 'bs', 'btm', 'ca', 'chr', 'ckb', 'co', 'cs', - 'csb', 'cy', 'da', 'de', 'diq', 'dv', 'el', 'en', 'eo', 'es', 'et', - 'eu', 'fa', 'fi', 'fj', 'fo', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', - 'gom', 'gor', 'gu', 'guw', 'gv', 'ha', 'he', 'hi', 'hif', 'hr', 'hsb', - 'hu', 'hy', 'ia', 'id', 'ie', 'ig', 'io', 'is', 'it', 'iu', 'ja', - 'jbo', 'jv', 'ka', 'kaa', 'kbd', 'kcg', 'kk', 'kl', 'km', 'kn', 'ko', - 'ks', 'ku', 'kw', 'ky', 'la', 'lb', 'li', 'lmo', 'ln', 'lo', 'lt', - 'lv', 'mad', 'mg', 'mi', 'min', 'mk', 'ml', 'mn', 'mni', 'mnw', 'mr', - 'ms', 'mt', 'my', 'na', 'nah', 'nds', 'ne', 'nia', 'nl', 'nn', 'no', - 'oc', 'om', 'or', 'pa', 'pl', 'pnb', 'ps', 'pt', 'qu', 'ro', 'roa-rup', - 'ru', 'rw', 'sa', 'sat', 'scn', 'sd', 'sg', 'sh', 'shn', 'shy', 'si', - 'simple', 'sk', 'skr', 'sl', 'sm', 'so', 'sq', 'sr', 'ss', 'st', 'su', - 'sv', 'sw', 'ta', 'tcy', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tn', - 'tpi', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vec', 'vi', 'vo', - 'wa', 'wo', 'yi', 'yue', 'zgh', 'zh', 'zh-min-nan', 'zu', + 'af', 'am', 'an', 'ang', 'ar', 'ast', 'ay', 'az', 'bcl', 'be', 'bew', + 'bg', 'bjn', 'blk', 'bn', 'br', 'bs', 'btm', 'ca', 'chr', 'ckb', 'co', + 'cs', 'csb', 'cy', 'da', 'de', 'diq', 'dv', 'el', 'en', 'eo', 'es', + 'et', 'eu', 'fa', 'fi', 'fj', 'fo', 'fr', 'fy', 'ga', 'gd', 'gl', + 'gn', 'gom', 'gor', 'gu', 'guw', 'gv', 'ha', 'he', 'hi', 'hif', 'hr', + 'hsb', 'hu', 'hy', 'ia', 'id', 'ie', 'ig', 'io', 'is', 'it', 'iu', + 'ja', 'jbo', 'jv', 'ka', 'kaa', 'kbd', 'kcg', 'kk', 'kl', 'km', 'kn', + 'ko', 'ks', 'ku', 'kw', 'ky', 'la', 'lb', 'li', 'lmo', 'ln', 'lo', + 'lt', 'lv', 'mad', 'mg', 'mi', 'min', 'mk', 'ml', 'mn', 'mni', 'mnw', + 'mr', 'ms', 'mt', 'my', 'na', 'nah', 'nds', 'ne', 'nia', 'nl', 'nn', + 'no', 'oc', 'om', 'or', 'pa', 'pl', 'pnb', 'ps', 'pt', 'qu', 'ro', + 'roa-rup', 'ru', 'rw', 'sa', 'sat', 'scn', 'sd', 'sg', 'sh', 'shn', + 'shy', 'si', 'simple', 'sk', 'skr', 'sl', 'sm', 'so', 'sq', 'sr', 'ss', + 'st', 'su', 'sv', 'sw', 'ta', 'tcy', 'te', 'tg', 'th', 'ti', 'tk', + 'tl', 'tn', 'tpi', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vec', + 'vi', 'vo', 'wa', 'wo', 'yi', 'yue', 'zgh', 'zh', 'zh-min-nan', 'zu', } category_redirect_templates = { From ae0a1b2a8a2c1198eef262a1cde6d5e9a218edd2 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 29 Aug 2025 17:08:48 +0200 Subject: [PATCH 161/279] [bugfix] Use 'User-Agent' with BinaryTestCase.test_requests User agent is needed due to T400119 Bug: T403271 Change-Id: Ie4932fa56bd8f856422cea383f162c5efce241e8 --- tests/http_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http_tests.py b/tests/http_tests.py index a9217424f3..e365242e67 100755 --- a/tests/http_tests.py +++ b/tests/http_tests.py @@ -480,7 +480,7 @@ def setUpClass(cls) -> None: def test_requests(self) -> None: """Test with requests, underlying package.""" with requests.Session() as s: - r = s.get(self.url) + r = s.get(self.url, headers={'User-Agent': http.user_agent()}) self.assertEqual(r.headers['content-type'], 'image/png') self.assertEqual(r.content, self.png) From 5246311adac1b0bbaf549092feaf4387cb2dcc6d Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 29 Aug 2025 19:35:25 +0200 Subject: [PATCH 162/279] Tests: ignore expected failures in interwiki_link_tests and site_tests Ignore expected failures in interwiki_link_tests.TestInterwikiLinksToNonLocalSites and site_tests.TestSingleCodeFamilySite Bug: T403292 Change-Id: Ib93b0c1ea9e21d108fda560fa1cb18f0aded3eb0 --- tests/interwiki_link_tests.py | 3 ++- tests/site_tests.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/interwiki_link_tests.py b/tests/interwiki_link_tests.py index 6df9dbc0c8..4fa376f682 100755 --- a/tests/interwiki_link_tests.py +++ b/tests/interwiki_link_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Test Interwiki Link functionality.""" # -# (C) Pywikibot team, 2014-2022 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -45,6 +45,7 @@ def test_partially_qualified_NS1_family(self) -> None: self.assertEqual(link.namespace, 1) +@unittest.expectedFailure # T403292 class TestInterwikiLinksToNonLocalSites(TestCase): """Tests for interwiki links to non local sites.""" diff --git a/tests/site_tests.py b/tests/site_tests.py index 2eb6207010..dfb58302c3 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -1039,6 +1039,7 @@ def test_linktrails(self) -> None: self.assertEqual(site.linktrail(), linktrail) +@unittest.expectedFailure # T403292 class TestSingleCodeFamilySite(AlteredDefaultSiteTestCase): """Test single code family sites.""" From e50700b10bfe403ae38ce9f191f349e7b10a93db Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 29 Aug 2025 15:23:49 +0200 Subject: [PATCH 163/279] site.allpages: apply client-side filtering for maxsize in misermode MediaWiki ignores `apmaxsize` when $wgMiserMode is enabled, which caused `site.allpages(maxsize=...)` to yield unfiltered results - Add `APIGeneratorBase.filter_func` and `APIGeneratorBase.filter_item` filter function in APIGeneratorBase - Call this filter in all generators of APIGeneratorBase subclasses. - Apply client-side filtering via `APIGeneratorBase.filter_func` if *maxsize* is set and the site runs in misermode. - Ensures page content is always loaded in this case, regardless of the *content* parameter, so that page lengths are available for filtering. - Marks all parameters except *start* as keyword-only for clarity. - remove `mysite.data_repository() == mysite` in test_allpages_pagesize test method which should be obsolete. Bug: T402995 Change-Id: If0cc80fb2047396a51feb6b986d4bec4c68d4643 --- pywikibot/data/api/_generators.py | 169 +++++++++++++++++++++++++----- pywikibot/site/_generators.py | 17 ++- tests/site_generators_tests.py | 11 +- 3 files changed, 159 insertions(+), 38 deletions(-) diff --git a/pywikibot/data/api/_generators.py b/pywikibot/data/api/_generators.py index 477dcb7e4b..26795cf561 100644 --- a/pywikibot/data/api/_generators.py +++ b/pywikibot/data/api/_generators.py @@ -43,12 +43,67 @@ class APIGeneratorBase(ABC): - """A wrapper class to handle the usage of the ``parameters`` parameter. + """Base class for all API and query request generators. + + Handles request cleaning and filtering. Each instance can have an + optional filter function applied to items before yielding. Set this + via the :attr:`filter_func` property, which should be a callable + accepting a single item and returning True to yield it, alse to skip + it. If :attr:`filter_func` is None, no filtering is applied. + + Subclasses can override :meth:`filter_item` for more complex + filtering logic. .. versionchanged:: 7.6 - renamed from _RequestWrapper + Renamed from _RequestWrapper. + .. versionchanged:: 10.4 + Introduced :attr:`filter_func` and :meth:`filter_item` for + instance-level item filtering. """ + _filter_func: Callable[[Any], bool] | None = None + + @property + def filter_func(self) -> Callable[[Any], bool] | None: + """Get the filter function for this generator instance. + + Returns the instance-specific filter if set, otherwise the + class-level default (None by default). + + .. versionadded:: 10.4 + + :return: Callable that accepts an item and returns True to + yield, False to skip; or None to disable filtering + """ + return getattr(self, '_filter_func', type(self)._filter_func) + + @filter_func.setter + def filter_func(self, func: Callable[[Any], bool] | None): + """Set a filter function to apply to items before yielding. + + .. versionadded:: 10.4 + + :param func: Callable that accepts an item and returns True to + yield, False to skip; or None to disable filtering + """ + self._filter_func = func + + def filter_item(self, item: Any) -> bool: + """Determine if a given item should be yielded. + + By default, applies :attr:`filter_func` if set. Returns True if + no filter is set. + + .. versionadded:: 10.4 + + :param item: The item to check + :return: True if the item should be yielded, False otherwise + """ + if self.filter_func is not None: + return self.filter_func(item) + + return True + def _clean_kwargs(self, kwargs, **mw_api_args): """Clean kwargs, define site and request class.""" if 'site' not in kwargs: @@ -162,13 +217,20 @@ def generator(self): """Submit request and iterate the response. Continues response as needed until limit (if defined) is reached. + Applies :meth:`filter_item()` to + each item before yielding. .. versionchanged:: 7.6 - changed from iterator method to generator property + Changed from iterator method to generator property + .. versionchanged:: 10.4 + Applies `filter_item` for instance-level filtering. + + :yield: Items from the MediaWiki API, filtered by `filter_item()` """ offset = self.starting_offset n = 0 while True: + # Set the continue parameter for the request self.request[self.continue_name] = offset pywikibot.debug(f'{type(self).__name__}: Request: {self.request}') data = self.request.submit() @@ -178,14 +240,17 @@ def generator(self): f'{type(self).__name__}: Retrieved {n_items} items') if n_items > 0: for item in data[self.data_name]: - yield item - n += 1 - if self.limit is not None and n >= self.limit: - pywikibot.debug( - f'{type(self).__name__}: Stopped iterating due to' - ' exceeding item limit.' - ) - return + # Apply the instance filter function before yielding + if self.filter_item(item): + yield item + n += 1 + # Stop iterating if the limit is reached + if self.limit is not None and n >= self.limit: + pywikibot.debug( + f'{type(self).__name__}: Stopped iterating due' + ' to exceeding item limit.' + ) + return offset += n_items else: pywikibot.debug(f'{type(self).__name__}: Stopped iterating' @@ -570,17 +635,36 @@ def _get_resultdata(self): return resultdata def _extract_results(self, resultdata): - """Extract results from resultdata.""" + """Extract results from resultdata, applying `filter_item()`. + + :attr:`generator` helper method which yields each result that + passes :meth:`filter_item() ` and + respects namespaces and the generator's limit. + + .. versionchanged:: 10.4 + Applies `filter_item()` for instance-level filtering. + + :param resultdata: List or iterable of raw API items + :yield: Processed items that pass the filter + :raises RuntimeError: if self.limit is reached + + :meta public: + """ for item in resultdata: result = self.result(item) if self._namespaces and not self._check_result_namespace(result): continue + # Apply the instance filter before yielding + if not self.filter_item(result): + continue + yield result modules_item_intersection = set(self.modules) & set(item) if isinstance(item, dict) and modules_item_intersection: - # if we need to count elements contained in items in + # Count elements contained in sub-items. + # If we need to count elements contained in items in # self.data["query"]["pages"], we want to count # item[self.modules] (e.g. 'revisions') and not # self.resultkey (i.e. 'pages') @@ -589,7 +673,8 @@ def _extract_results(self, resultdata): # otherwise we proceed as usual else: self._count += 1 - # note: self.limit could be -1 + + # Stop if limit is reached; note: self.limit could be -1 if self.limit and 0 < self.limit <= self._count: raise RuntimeError( 'QueryGenerator._extract_results reached the limit') @@ -599,9 +684,15 @@ def generator(self): """Submit request and iterate the response based on self.resultkey. Continues response as needed until limit (if any) is reached. + Each item is already filtered by `_extract_results()`. .. versionchanged:: 7.6 - changed from iterator method to generator property + Changed from iterator method to generator property + .. versionchanged:: 10.4 + Items are filtered via :meth:`filter_item() + ` inside :meth:`_extract_results`. + + :yield: Items from the API, already filtered """ previous_result_had_data = True prev_limit = new_limit = None @@ -616,7 +707,7 @@ def generator(self): if not self.data or not isinstance(self.data, dict): pywikibot.debug(f'{type(self).__name__}: stopped iteration' - ' because no dict retrieved from api.') + ' because no dict retrieved from API.') break if 'query' in self.data and self.resultkey in self.data['query']: @@ -638,13 +729,13 @@ def generator(self): else: if 'query' not in self.data: pywikibot.log(f"{type(self).__name__}: 'query' not found" - ' in api response.') + ' in API response.') pywikibot.log(str(self.data)) # if (query-)continue is present, self.resultkey might not have # been fetched yet if self.continue_name not in self.data: - break # No results. + break # No results # self.resultkey not in data in last request.submit() # only "(query-)continue" was retrieved. @@ -767,13 +858,18 @@ class PropertyGenerator(QueryGenerator): decide what to do with the contents of the dict. There will be one dict for each page queried via a titles= or ids= parameter (which must be supplied when instantiating this class). + + .. versionchanged:: 10.4 + Supports instance-level filtering via :attr:`filter_func + ` / :meth:`filter_item() + None: """Initializer. - Required and optional parameters are as for ``Request``, except that - action=query is assumed and prop is required. + Required and optional parameters are as for ``Request``, except + that action=query is assumed and prop is required. :param prop: the "prop=" type from api.php """ @@ -781,6 +877,7 @@ def __init__(self, prop: str, **kwargs) -> None: super().__init__(**kwargs) self._props = frozenset(prop.split('|')) self.resultkey = 'pages' + self._previous_dicts: dict[str, dict] = {} @property def props(self): @@ -789,17 +886,28 @@ def props(self): @property def generator(self): - """Yield results. + """Yield results from the API, including previously retrieved dicts. .. versionchanged:: 7.6 - changed from iterator method to generator property + Changed from iterator method to generator property. + + .. versionchanged:: 10.4 + Items are filtered via :meth:`filter_item() + None: def test_allpages_pagesize(self) -> None: """Test allpages with page maxsize parameter.""" mysite = self.get_site() + encoding = mysite.encoding() for page in mysite.allpages(minsize=100, total=5): self.assertIsInstance(page, pywikibot.Page) self.assertTrue(page.exists()) - self.assertGreaterEqual(len(page.text.encode(mysite.encoding())), - 100) + self.assertGreaterEqual(len(page.text.encode(encoding)), 100) for page in mysite.allpages(maxsize=200, total=5): self.assertIsInstance(page, pywikibot.Page) self.assertTrue(page.exists()) - if len(page.text.encode(mysite.encoding())) > 200 \ - and mysite.data_repository() == mysite: # pragma: no cover - unittest_print( - f'{page}.text is > 200 bytes while raw JSON is <= 200') - continue - self.assertLessEqual(len(page.text.encode(mysite.encoding())), 200) + self.assertLessEqual(len(page.text.encode(encoding)), 200) def test_allpages_protection(self) -> None: """Test allpages with protect_type parameter.""" From d23dd981f005dad82a262843226a2f5f1ba4b57f Mon Sep 17 00:00:00 2001 From: Xqt Date: Fri, 29 Aug 2025 18:28:23 +0000 Subject: [PATCH 164/279] Revert "Tests: ignore expected failures in interwiki_link_tests and site_tests" This reverts commit 5246311adac1b0bbaf549092feaf4387cb2dcc6d. Reason for revert: Does not work as expected Change-Id: Ia81b40cfa2707eb1f26d0f4f9b3998d72105857b --- tests/interwiki_link_tests.py | 3 +-- tests/site_tests.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/interwiki_link_tests.py b/tests/interwiki_link_tests.py index 4fa376f682..6df9dbc0c8 100755 --- a/tests/interwiki_link_tests.py +++ b/tests/interwiki_link_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Test Interwiki Link functionality.""" # -# (C) Pywikibot team, 2014-2025 +# (C) Pywikibot team, 2014-2022 # # Distributed under the terms of the MIT license. # @@ -45,7 +45,6 @@ def test_partially_qualified_NS1_family(self) -> None: self.assertEqual(link.namespace, 1) -@unittest.expectedFailure # T403292 class TestInterwikiLinksToNonLocalSites(TestCase): """Tests for interwiki links to non local sites.""" diff --git a/tests/site_tests.py b/tests/site_tests.py index dfb58302c3..2eb6207010 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -1039,7 +1039,6 @@ def test_linktrails(self) -> None: self.assertEqual(site.linktrail(), linktrail) -@unittest.expectedFailure # T403292 class TestSingleCodeFamilySite(AlteredDefaultSiteTestCase): """Test single code family sites.""" From e65252e1df12cbe5e0668c958a11944538835398 Mon Sep 17 00:00:00 2001 From: Xqt Date: Fri, 29 Aug 2025 20:05:18 +0000 Subject: [PATCH 165/279] update coverage settings Change-Id: I9c37363bb4824cd4b69dd674511c7facad2a4f1e Signed-off-by: Xqt --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 890995f540..3f3e66a98d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,7 +143,7 @@ exclude_also = [ "except \\w*ServerError", "if (0|False):", "if .+PYWIKIBOT_TEST_\\w+.+:", - "if TYPE_CHECKING:", + "if (typing\\.)?TYPE_CHECKING:", "if __debug__:", "if __name__ == .__main__.:", "if self\\.mw_version < .+:", From 384289ada5c3384bf0d96cf8c14ddb8feddc84f5 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 30 Aug 2025 10:26:18 +0200 Subject: [PATCH 166/279] Request: use unittest_print to show Exception in Rweusests._http_request Bug: T403292 Change-Id: I26f555eb074a0452a16fc0952c0e071792b3f0d8 --- pywikibot/data/api/_requests.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pywikibot/data/api/_requests.py b/pywikibot/data/api/_requests.py index 0186acce15..07fa07f21a 100644 --- a/pywikibot/data/api/_requests.py +++ b/pywikibot/data/api/_requests.py @@ -49,6 +49,8 @@ if TEST_RUNNING: import unittest + # lazy load unittest_print to prevent circular imports + # Actions that imply database updates on the server, used for various # things like throttling or skipping actions when we're in simulation # mode @@ -716,8 +718,15 @@ def _http_request(self, use_get: bool, uri: str, data, headers, # TODO: what other exceptions can occur here? except Exception: # for any other error on the http request, wait and retry - pywikibot.error(traceback.format_exc()) - pywikibot.log(f'{uri}, {paramstring}') + tb = traceback.format_exc() + msg = f'{uri}, {paramstring}' + if TEST_RUNNING: + from tests import unittest_print + unittest_print(tb) + unittest_print(msg) + else: + pywikibot.error(tb) + pywikibot.log(msg) else: return response, use_get From bdbdd7a1fa911f3b3667e3087c7a4ffda92f6a7f Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 30 Aug 2025 09:16:20 +0200 Subject: [PATCH 167/279] Doc: update ROADMAP.rst, CHANGELOG.rst and AUTHORS.rst Change-Id: I8da2d14974819b66d2eb4f789cdb189c0440fc86 --- AUTHORS.rst | 4 +++- ROADMAP.rst | 28 ++++++++++++++++++++++++++-- scripts/CHANGELOG.rst | 5 +++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 3e29b4eae8..fa3bf8bff9 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -191,6 +191,7 @@ L :: + Lars G Legoktm Leonardo Gregianin Lewis Cawte @@ -381,7 +382,6 @@ Y Yrithinnd Yuri Astrakhan Yusuke Matsubara - Zaher Kadour Z - @@ -389,5 +389,7 @@ Z :: + Zabe + Zaher Kadour zhuyifei1999 Zoran Dori diff --git a/ROADMAP.rst b/ROADMAP.rst index 059e3be30f..50ff705f72 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,9 +1,29 @@ Current Release Changes ======================= +* Apply client-side filtering for *maxsize* in misermode in + :meth:`Site.allpages()` (:phab:`T402995`) +* Add :attr:`filter_func()` and :meth:`filter_item() + ` filter function in :class:`APIGeneratorBase + ` and modify `generator` property to implement filtering in + `APIGeneratorBase` subclasses (:phab:`T402995`) +* All parameters of :meth:`Site.allpages()` + except *start* must be given as keyword arguments. +* Add support for bewwiktionary (:phab:`T402136`) +* Add user-agent header to :mod:`eventstreams` requests (:phab:`T402796`) +* Update i18n +* Save global options in :attr:`bot.global_args` (:phab:`T250034`) +* Update :mod:`plural` forms from unicode.org (:phab:`T114978`) +* Add :class:`textlib.SectionList` to hold :attr:`textlib.Content.sections` (:phab:`T401464`) +* :class:`pywikibot.Coordinate` parameters are keyword only +* Add *strict* parameter to :meth:`Site.unconnected_pages() + ` and :func:`pagegenerators.UnconnectedPageGenerator` + (:phab:`T401699`) +* Raise ValueError if a VAR_POSITIONAL parameter like *\*args* is used with + :class:`tools.deprecate_positionals` decorator * Add :meth:`get_value_at_timestamp()` API - to :class:`pywikibot.ItemPage` (:phab:`T400612`) -* Cleanup :mod:`setup` module (:phab:`T396356`) + to :class:`pywikibot.ItemPage` (:phab:`T400612`) +* Clean up :mod:`setup` module (:phab:`T396356`) * Implement :meth:`pywikibot.ItemPage.get_best_claim` (:phab:`T400610`) * Add *expiry* parameter to :meth:`BasePage.watch()` and :meth:`Site.watch()`; fix the methods to return False if @@ -16,6 +36,10 @@ Deprecations Pending removal in Pywikibot 13 ------------------------------- +* 10.4.0: Require all parameters of :meth:`Site.allpages() + ` except *start* to be keyword arguments. +* 10.4.0: Positional arguments of :class:`pywikibot.Coordinate` are deprecated and must be given as + keyword arguments. * 10.3.0: :meth:`throttle.Throttle.getDelay` and :meth:`throttle.Throttle.setDelays` were renamed to :meth:`get_delay()` and :meth:`set_delays() `; the old methods will be removed (:phab:`T289318`) diff --git a/scripts/CHANGELOG.rst b/scripts/CHANGELOG.rst index 4c90f443df..b4e4da639c 100644 --- a/scripts/CHANGELOG.rst +++ b/scripts/CHANGELOG.rst @@ -4,6 +4,11 @@ Scripts Changelog 10.4.0 ------ +addwikis +^^^^^^^^ + +* Add help options for addwikis script whereas `help` is deprecated. + interwiki ^^^^^^^^^ From 61c2e1bb268c018ebb8878cd0af51cfbb0efc190 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 30 Aug 2025 15:11:55 +0200 Subject: [PATCH 168/279] Test: convert TestSingleCodeFamilySite to dry test Replace `AlteredDefaultSiteTestCase` with `DefaultDrySiteTestCase` to avoid network requests to translatewiki.net. This prevents CI failures due to blocked connections while still verifying family and site properties. Bug: T403292 Change-Id: I0ff6b0dc8a80490ed6ae60cb701263005b841375 --- tests/site_tests.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/site_tests.py b/tests/site_tests.py index 2eb6207010..d46341184c 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -28,6 +28,7 @@ ) from tests.aspects import ( AlteredDefaultSiteTestCase, + DefaultDrySiteTestCase, DefaultSiteTestCase, DeprecationTestCase, TestCase, @@ -1039,28 +1040,23 @@ def test_linktrails(self) -> None: self.assertEqual(site.linktrail(), linktrail) -class TestSingleCodeFamilySite(AlteredDefaultSiteTestCase): +class TestSingleCodeFamilySite(DefaultDrySiteTestCase): """Test single code family sites.""" - sites = { - 'i18n': { - 'family': 'i18n', - 'code': 'i18n', - }, - } + family = 'i18n' + code = 'i18n' def test_twn(self) -> None: """Test translatewiki.net.""" url = 'translatewiki.net' - site = self.get_site('i18n') - self.assertEqual(site.hostname(), url) + site = self.get_site() self.assertEqual(site.code, 'i18n') self.assertIsInstance(site.namespaces, Mapping) self.assertFalse(site.obsolete) - self.assertEqual(site.family.hostname('en'), url) - self.assertEqual(site.family.hostname('i18n'), url) - self.assertEqual(site.family.hostname('translatewiki'), url) + self.assertEqual(site.hostname(), url) + for code in 'en', 'i18n', 'translatewiki': + self.assertEqual(site.family.hostname(code), url) class TestSubdomainFamilySite(TestCase): From e7f80787a70029ad4e2dd75683881a2863eb5857 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 30 Aug 2025 16:14:05 +0200 Subject: [PATCH 169/279] tests: Print public IP of runner on failure Change-Id: Ie55afe4c2c59de06de1cf9e3f9fd99f3e8f0f7a4 --- .github/workflows/pywikibot-ci.yml | 2 ++ .github/workflows/windows_tests.yml | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 46f51d8f2a..54a8b7b6fe 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -149,4 +149,6 @@ jobs: - name: Check on failure if: steps.ci_test.outcome == 'failure' run: | + # Print public IP of runner + curl -s https://api.ipify.org exit 1 diff --git a/.github/workflows/windows_tests.yml b/.github/workflows/windows_tests.yml index 34c3a5b4be..c78e045f9a 100644 --- a/.github/workflows/windows_tests.yml +++ b/.github/workflows/windows_tests.yml @@ -83,4 +83,7 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Check on failure if: steps.ci_test.outcome == 'failure' - run: exit 1 + run: | + # Print public IP of runner + curl -s https://api.ipify.org + exit 1 From 4ed52c13a1a05a624d1c7c11be8687e7bf69f862 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 30 Aug 2025 17:15:31 +0200 Subject: [PATCH 170/279] tests: skip TestInterwikiLinksToNonLocalSites on Github Skip interwiki_link_tests.TestInterwikiLinksToNonLocalSites on Github due to network access being blocked on translatewiki.net (hopefully temporary). Tests are still running on Jenkins CI. Bug: T403292 Change-Id: I510046cdd25002654143811cdf9e515ba1835843 --- tests/interwiki_link_tests.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/interwiki_link_tests.py b/tests/interwiki_link_tests.py index 6df9dbc0c8..f3e83bf079 100755 --- a/tests/interwiki_link_tests.py +++ b/tests/interwiki_link_tests.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """Test Interwiki Link functionality.""" # -# (C) Pywikibot team, 2014-2022 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # from __future__ import annotations +import os from contextlib import suppress from pywikibot import config @@ -45,6 +46,8 @@ def test_partially_qualified_NS1_family(self) -> None: self.assertEqual(link.namespace, 1) +@unittest.skipIf(os.environ.get('GITHUB_ACTIONS'), + 'Tests blocked on twn, see T403292') class TestInterwikiLinksToNonLocalSites(TestCase): """Tests for interwiki links to non local sites.""" From 94dc17685cf759f1a742920eafd5401010ad8970 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 30 Aug 2025 19:54:35 +0200 Subject: [PATCH 171/279] [10.4.0] Publish Pywikibot 10.4 Change-Id: I72a2368e861f441783fb5517f82ba3ddf0c87881 --- pywikibot/__metadata__.py | 2 +- scripts/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index 663b66e0db..8481806133 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.4.0.dev2' +__version__ = '10.4.0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index ea677d36e7..538369b419 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -19,7 +19,7 @@ description = "Pywikibot Scripts Collection" readme = "scripts/README.rst" requires-python = ">=3.8.0" dependencies = [ - "pywikibot >= 10.2.0", + "pywikibot >= 10.4.0", "isbnlib", "langdetect", "mwparserfromhell", From 0a0916a5c044d33db1d7fee5c8c56e41e5d5312a Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 31 Aug 2025 16:38:15 +0200 Subject: [PATCH 172/279] [10.5] Prepare next release Change-Id: I1714a26f7335e8cac050a8325a1c48c82eb6ca64 --- .pre-commit-config.yaml | 2 +- HISTORY.rst | 33 +++++++++++++++++++++++++++++++++ ROADMAP.rst | 28 +--------------------------- pywikibot/__metadata__.py | 2 +- scripts/__init__.py | 2 +- scripts/pyproject.toml | 2 +- 6 files changed, 38 insertions(+), 31 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9bcdf70737..abfaca0cb8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.10 + rev: v0.12.11 hooks: - id: ruff-check alias: ruff diff --git a/HISTORY.rst b/HISTORY.rst index 9a4b6dc724..46ea47171e 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,39 @@ Release History =============== +10.4.0 +------ +*31 August 2025* + +* Apply client-side filtering for *maxsize* in misermode in + :meth:`Site.allpages()` (:phab:`T402995`) +* Add :attr:`filter_func()` and :meth:`filter_item() + ` filter function in :class:`APIGeneratorBase + ` and modify `generator` property to implement filtering in + `APIGeneratorBase` subclasses (:phab:`T402995`) +* All parameters of :meth:`Site.allpages()` + except *start* must be given as keyword arguments. +* Add support for bewwiktionary (:phab:`T402136`) +* Add user-agent header to :mod:`eventstreams` requests (:phab:`T402796`) +* Update i18n +* Save global options in :attr:`bot.global_args` (:phab:`T250034`) +* Update :mod:`plural` forms from unicode.org (:phab:`T114978`) +* Add :class:`textlib.SectionList` to hold :attr:`textlib.Content.sections` (:phab:`T401464`) +* :class:`pywikibot.Coordinate` parameters are keyword only +* Add *strict* parameter to :meth:`Site.unconnected_pages() + ` and :func:`pagegenerators.UnconnectedPageGenerator` + (:phab:`T401699`) +* Raise ValueError if a VAR_POSITIONAL parameter like *\*args* is used with + :class:`tools.deprecate_positionals` decorator +* Add :meth:`get_value_at_timestamp()` API + to :class:`pywikibot.ItemPage` (:phab:`T400612`) +* Clean up :mod:`setup` module (:phab:`T396356`) +* Implement :meth:`pywikibot.ItemPage.get_best_claim` (:phab:`T400610`) +* Add *expiry* parameter to :meth:`BasePage.watch()` and + :meth:`Site.watch()`; fix the methods to return False if + page is missing and no expiry is set (:phab:`T330839`) + + 10.3.2 ------ *12 August 2025* diff --git a/ROADMAP.rst b/ROADMAP.rst index 50ff705f72..b9594757e8 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,33 +1,7 @@ Current Release Changes ======================= -* Apply client-side filtering for *maxsize* in misermode in - :meth:`Site.allpages()` (:phab:`T402995`) -* Add :attr:`filter_func()` and :meth:`filter_item() - ` filter function in :class:`APIGeneratorBase - ` and modify `generator` property to implement filtering in - `APIGeneratorBase` subclasses (:phab:`T402995`) -* All parameters of :meth:`Site.allpages()` - except *start* must be given as keyword arguments. -* Add support for bewwiktionary (:phab:`T402136`) -* Add user-agent header to :mod:`eventstreams` requests (:phab:`T402796`) -* Update i18n -* Save global options in :attr:`bot.global_args` (:phab:`T250034`) -* Update :mod:`plural` forms from unicode.org (:phab:`T114978`) -* Add :class:`textlib.SectionList` to hold :attr:`textlib.Content.sections` (:phab:`T401464`) -* :class:`pywikibot.Coordinate` parameters are keyword only -* Add *strict* parameter to :meth:`Site.unconnected_pages() - ` and :func:`pagegenerators.UnconnectedPageGenerator` - (:phab:`T401699`) -* Raise ValueError if a VAR_POSITIONAL parameter like *\*args* is used with - :class:`tools.deprecate_positionals` decorator -* Add :meth:`get_value_at_timestamp()` API - to :class:`pywikibot.ItemPage` (:phab:`T400612`) -* Clean up :mod:`setup` module (:phab:`T396356`) -* Implement :meth:`pywikibot.ItemPage.get_best_claim` (:phab:`T400610`) -* Add *expiry* parameter to :meth:`BasePage.watch()` and - :meth:`Site.watch()`; fix the methods to return False if - page is missing and no expiry is set (:phab:`T330839`) +* (no changes yet) Deprecations diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index 8481806133..f27890405a 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.4.0' +__version__ = '10.5.0.dev0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' diff --git a/scripts/__init__.py b/scripts/__init__.py index e42926ff70..f941522e5f 100644 --- a/scripts/__init__.py +++ b/scripts/__init__.py @@ -34,7 +34,7 @@ from pathlib import Path -__version__ = '10.4.0' +__version__ = '10.5.0' #: defines the entry point for pywikibot-scripts package base_dir = Path(__file__).parent diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index 538369b419..0886321349 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -7,7 +7,7 @@ package-dir = {"pywikibot_scripts" = "scripts"} [project] name = "pywikibot-scripts" -version = "10.4.0" +version = "10.5.0" authors = [ {name = "xqt", email = "info@gno.de"}, From 77800b2f405df2278bbdaf94479df6da4a37f510 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 31 Aug 2025 18:04:32 +0200 Subject: [PATCH 173/279] doc: first line of docstring should be in imperative mood Change-Id: I4cfe9c07af5255ef3231b8782a0d60f79f48be92 --- tests/add_text_tests.py | 2 +- tests/bot_tests.py | 2 +- tests/collections_tests.py | 6 +++--- tests/eventstreams_tests.py | 2 +- tests/interwiki_graph_tests.py | 4 ++-- tests/interwikimap_tests.py | 4 ++-- tests/link_tests.py | 20 ++++++++++---------- tests/page_tests.py | 2 +- tests/pagegenerators_tests.py | 10 +++++----- tests/site_generators_tests.py | 6 +++--- tests/site_tests.py | 2 +- tests/textlib_tests.py | 2 +- tests/tools_tests.py | 2 +- tests/ui_tests.py | 2 +- tests/wbtypes_tests.py | 4 ++-- tests/wikibase_edit_tests.py | 4 ++-- tests/wikibase_tests.py | 22 +++++++++++----------- 17 files changed, 48 insertions(+), 48 deletions(-) diff --git a/tests/add_text_tests.py b/tests/add_text_tests.py index e032cb1980..7c26c71c50 100755 --- a/tests/add_text_tests.py +++ b/tests/add_text_tests.py @@ -46,7 +46,7 @@ class TestAddTextScript(TestCase): dry = True def setUp(self) -> None: - """Setup test.""" + """Set up test.""" super().setUp() pywikibot.bot.ui.clear() self.generator_factory = pywikibot.pagegenerators.GeneratorFactory() diff --git a/tests/bot_tests.py b/tests/bot_tests.py index d5df8b44a3..ef53c57262 100755 --- a/tests/bot_tests.py +++ b/tests/bot_tests.py @@ -301,7 +301,7 @@ class TestOptionHandler(TestCase): dry = True def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" self.option_handler = Options(baz=True) super().setUp() diff --git a/tests/collections_tests.py b/tests/collections_tests.py index db9bbc8f31..29754bf2be 100755 --- a/tests/collections_tests.py +++ b/tests/collections_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for the Wikidata parts of the page module.""" # -# (C) Pywikibot team, 2019-2022 +# (C) Pywikibot team, 2019-2025 # # Distributed under the terms of the MIT license. # @@ -44,7 +44,7 @@ class TestLanguageDict(DataCollectionTestCase): dry = True def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.site = self.get_site() self.lang_out = {'en': 'foo', 'zh': 'bar'} @@ -132,7 +132,7 @@ class TestAliasesDict(DataCollectionTestCase): dry = True def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.site = self.get_site() self.lang_out = {'en': ['foo', 'bar'], diff --git a/tests/eventstreams_tests.py b/tests/eventstreams_tests.py index 35d4a8c7d7..e125901977 100755 --- a/tests/eventstreams_tests.py +++ b/tests/eventstreams_tests.py @@ -77,7 +77,7 @@ class TestEventStreamsStreamsTests(DefaultSiteTestCase): """Stream tests for eventstreams module.""" def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() site = self.get_site() fam = site.family diff --git a/tests/interwiki_graph_tests.py b/tests/interwiki_graph_tests.py index 91fedc7e4a..eeb9cdb6e3 100755 --- a/tests/interwiki_graph_tests.py +++ b/tests/interwiki_graph_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Test Interwiki Graph functionality.""" # -# (C) Pywikibot team, 2015-2022 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -38,7 +38,7 @@ class TestWiktionaryGraph(SiteAttributeTestCase): @classmethod def setUpClass(cls) -> None: - """Setup test class.""" + """Set up test class.""" super().setUpClass() cls.pages = { diff --git a/tests/interwikimap_tests.py b/tests/interwikimap_tests.py index ae3aa65a9c..b0cf64291e 100755 --- a/tests/interwikimap_tests.py +++ b/tests/interwikimap_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for the site module.""" # -# (C) Pywikibot team, 2018-2024 +# (C) Pywikibot team, 2018-2025 # # Distributed under the terms of the MIT license. # @@ -112,7 +112,7 @@ class TestInterwikiMapPrefix(TestCase): code = 'en' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.iw_map = self.site._interwikimap diff --git a/tests/link_tests.py b/tests/link_tests.py index 25e0160248..960deed6a0 100755 --- a/tests/link_tests.py +++ b/tests/link_tests.py @@ -241,7 +241,7 @@ class LinkTestWikiEn(LinkTestCase): code = 'en' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'en' config.family = 'wikipedia' @@ -313,7 +313,7 @@ class TestPartiallyQualifiedExplicitLinkDifferentFamilyParser(LinkTestCase): code = 'en' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'en' config.family = 'wikisource' @@ -389,7 +389,7 @@ class TestFullyQualifiedLinkDifferentFamilyParser(LinkTestCase): PATTERN = '{colon}{first}:{second}:{title}' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'en' config.family = 'wikisource' @@ -439,7 +439,7 @@ class TestFullyQualifiedExplicitLinkNoLangConfigFamilyParser(LinkTestCase): code = 'en' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'wikidata' config.family = 'wikidata' @@ -493,7 +493,7 @@ class TestFullyQualifiedNoLangFamilyExplicitLinkParser(LinkTestCase): } def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'en' config.family = 'wikipedia' @@ -527,7 +527,7 @@ class TestFullyQualifiedOneSiteFamilyExplicitLinkParser(LinkTestCase): code = 'species' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'en' config.family = 'wikipedia' @@ -618,7 +618,7 @@ class TestPartiallyQualifiedImplicitLinkDifferentFamilyParser(LinkTestCase): code = 'en' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'en' config.family = 'wikisource' @@ -669,7 +669,7 @@ class TestFullyQualifiedImplicitLinkNoLangConfigFamilyParser(LinkTestCase): code = 'en' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'wikidata' config.family = 'wikidata' @@ -715,7 +715,7 @@ class TestFullyQualifiedNoLangFamilyImplicitLinkParser(LinkTestCase): code = 'test' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'en' config.family = 'wikipedia' @@ -753,7 +753,7 @@ class TestFullyQualifiedOneSiteFamilyImplicitLinkParser(LinkTestCase): code = 'species' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() config.mylang = 'en' config.family = 'wikipedia' diff --git a/tests/page_tests.py b/tests/page_tests.py index 3baebeeba9..688e02ea1f 100755 --- a/tests/page_tests.py +++ b/tests/page_tests.py @@ -728,7 +728,7 @@ class TestPageBotMayEdit(TestCase): login = True def setUp(self) -> None: - """Setup test.""" + """Set up test.""" super().setUp() self.page = pywikibot.Page(self.site, 'not_existent_page_for_pywikibot_tests') diff --git a/tests/pagegenerators_tests.py b/tests/pagegenerators_tests.py index 9a651d7ba7..f2aa0b4a9a 100755 --- a/tests/pagegenerators_tests.py +++ b/tests/pagegenerators_tests.py @@ -73,7 +73,7 @@ class TestDryPageGenerators(TestCase): titles = en_wp_page_titles + en_wp_nopage_titles def setUp(self) -> None: - """Setup test.""" + """Set up test.""" super().setUp() self.site = self.get_site() @@ -202,7 +202,7 @@ class BasetitleTestCase(TestCase): 'Calf Case.pdf/{}') def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.site = self.get_site() self.titles = [self.base_title.format(i) for i in range(1, 11)] @@ -228,7 +228,7 @@ class TestCategoryFilterPageGenerator(BasetitleTestCase): category_list = ['Category:Validated'] def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.catfilter_list = [pywikibot.Category(self.site, cat) for cat in self.category_list] @@ -1427,7 +1427,7 @@ class TestWantedFactoryGenerator(DefaultSiteTestCase): """Test pagegenerators.GeneratorFactory for wanted pages.""" def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.gf = pagegenerators.GeneratorFactory(site=self.site) @@ -1547,7 +1547,7 @@ class TestLogeventsFactoryGenerator(DefaultSiteTestCase, @classmethod def setUpClass(cls) -> None: - """Setup test class.""" + """Set up test class.""" super().setUpClass() site = pywikibot.Site() newuser_logevents = list(site.logevents(logtype='newusers', total=1)) diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index f395c7062e..92e4443684 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -1662,7 +1662,7 @@ class TestSiteLoadRevisions(TestCase): # Implemented without setUpClass(cls) and global variables as objects # were not completely disposed and recreated but retained 'memory' def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.mysite = self.get_site() self.mainpage = pywikibot.Page(pywikibot.Link('Main Page', @@ -1795,7 +1795,7 @@ class TestBacklinks(TestCase): cached = True def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.page = pywikibot.Page(self.site, 'File:BoA – Woman.png') self.backlinks = list(self.page.backlinks(follow_redirects=False, @@ -1910,7 +1910,7 @@ class TestLoadPagesFromPageids(DefaultSiteTestCase): cached = True def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.site = self.get_site() mainpage = self.get_mainpage() diff --git a/tests/site_tests.py b/tests/site_tests.py index d46341184c..8c3df54868 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -825,7 +825,7 @@ class TestSiteLoadRevisionsCaching(BasePageLoadRevisionsCachingTestBase, """Test site.loadrevisions() caching.""" def setup_page(self) -> None: - """Setup test page.""" + """Set up test page.""" self._page = self.get_mainpage(force=True) def test_page_text(self) -> None: diff --git a/tests/textlib_tests.py b/tests/textlib_tests.py index 4f4ab28af9..baf0c2feb0 100755 --- a/tests/textlib_tests.py +++ b/tests/textlib_tests.py @@ -43,7 +43,7 @@ def setUpClass(cls) -> None: cls.content = file.read_text(encoding='utf-8') def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" self.catresult1 = '[[Category:Cat1]]\n[[Category:Cat2]]\n' super().setUp() diff --git a/tests/tools_tests.py b/tests/tools_tests.py index 91d2604929..533ee25f8d 100755 --- a/tests/tools_tests.py +++ b/tests/tools_tests.py @@ -1016,7 +1016,7 @@ class TestTinyCache(TestCase): net = False def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" self.foo = DecoratedMethods() super().setUp() diff --git a/tests/ui_tests.py b/tests/ui_tests.py index ebfc695a34..74da260b77 100755 --- a/tests/ui_tests.py +++ b/tests/ui_tests.py @@ -51,7 +51,7 @@ class UITestCase(TestCaseBase): net = False def setUp(self) -> None: - """Setup test. + """Set up test. Here we patch standard input, output, and errors, essentially redirecting to `StringIO` streams. diff --git a/tests/wbtypes_tests.py b/tests/wbtypes_tests.py index 95ce78bfb9..c9ac6da42d 100755 --- a/tests/wbtypes_tests.py +++ b/tests/wbtypes_tests.py @@ -853,7 +853,7 @@ class TestWbGeoShapeNonDry(WbRepresentationTestCase): """ def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" self.commons = pywikibot.Site('commons') self.page = Page(self.commons, 'Data:Lyngby Hovedgade.map') super().setUp() @@ -928,7 +928,7 @@ class TestWbTabularDataNonDry(WbRepresentationTestCase): """ def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" self.commons = pywikibot.Site('commons') self.page = Page(self.commons, 'Data:Bea.gov/GDP by state.tab') super().setUp() diff --git a/tests/wikibase_edit_tests.py b/tests/wikibase_edit_tests.py index 89a98fce8e..2e62130b7b 100755 --- a/tests/wikibase_edit_tests.py +++ b/tests/wikibase_edit_tests.py @@ -5,7 +5,7 @@ class in edit_failure_tests.py """ # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -540,7 +540,7 @@ class TestWikibaseDataSiteWbsetActions(WikibaseTestCase): write = True def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" self.testsite = self.get_repo() self.item = pywikibot.ItemPage(self.testsite, 'Q68') badge = pywikibot.ItemPage(self.testsite, 'Q608') diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py index 89592bd14c..583123f12c 100755 --- a/tests/wikibase_tests.py +++ b/tests/wikibase_tests.py @@ -51,7 +51,7 @@ class TestLoadRevisionsCaching(BasePageLoadRevisionsCachingTestBase, """Test site.loadrevisions() caching.""" def setup_page(self) -> None: - """Setup test page.""" + """Set up test page.""" self._page = ItemPage(self.get_repo(), 'Q15169668') def test_page_text(self) -> None: @@ -66,7 +66,7 @@ class TestGeneral(WikidataTestCase): @classmethod def setUpClass(cls) -> None: - """Setup test class.""" + """Set up test class.""" super().setUpClass() enwiki = pywikibot.Site('en', 'wikipedia') cls.mainpage = pywikibot.Page(pywikibot.page.Link('Main Page', enwiki)) @@ -163,7 +163,7 @@ class TestLoadUnknownType(WikidataTestCase): dry = True def setUp(self) -> None: - """Setup test.""" + """Set up test.""" super().setUp() wikidata = self.get_repo() self.wdp = ItemPage(wikidata, 'Q60') @@ -230,12 +230,12 @@ class TestItemLoad(WikidataTestCase): @classmethod def setUpClass(cls) -> None: - """Setup test class.""" + """Set up test class.""" super().setUpClass() cls.site = cls.get_site('enwiki') def setUp(self) -> None: - """Setup test.""" + """Set up test.""" super().setUp() self.nyc = pywikibot.Page(pywikibot.page.Link('New York City', self.site)) @@ -1017,7 +1017,7 @@ class TestItemBasePageMethods(WikidataTestCase, BasePageMethodsTestBase): """Test behavior of ItemPage methods inherited from BasePage.""" def setup_page(self) -> None: - """Setup test page.""" + """Set up test page.""" self._page = ItemPage(self.get_repo(), 'Q60') def test_basepage_methods(self) -> None: @@ -1036,7 +1036,7 @@ class TestPageMethodsWithItemTitle(WikidataTestCase, BasePageMethodsTestBase): """Test behavior of Page methods for wikibase item.""" def setup_page(self) -> None: - """Setup tests.""" + """Set up tests.""" self._page = pywikibot.Page(self.site, 'Q60') def test_basepage_methods(self) -> None: @@ -1066,7 +1066,7 @@ class TestLinks(WikidataTestCase): } def setUp(self) -> None: - """Setup Tests.""" + """Set up tests.""" super().setUp() self.wdp = ItemPage(self.get_repo(), 'Q60') self.wdp.id = 'Q60' @@ -1100,7 +1100,7 @@ class TestWriteNormalizeData(TestCase): net = False def setUp(self) -> None: - """Setup tests.""" + """Set up tests.""" super().setUp() self.data_out = { 'labels': {'en': {'language': 'en', 'value': 'Foo'}}, @@ -1299,7 +1299,7 @@ class TestAlternateNamespaces(WikidataTestCase): @classmethod def setUpClass(cls) -> None: - """Setup test class.""" + """Set up test class.""" super().setUpClass() cls.get_repo()._namespaces = NamespacesDict({ @@ -1436,7 +1436,7 @@ class TestJSON(WikidataTestCase): """Test cases to test toJSON() functions.""" def setUp(self) -> None: - """Setup test.""" + """Set up test.""" super().setUp() wikidata = self.get_repo() self.wdp = ItemPage(wikidata, 'Q60') From c66142b9a3ddd34c57af69760f0e859099f17e63 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 31 Aug 2025 19:11:40 +0200 Subject: [PATCH 174/279] doc: Refactor docstrings of category.py script Change-Id: Ifd9dbdb9b5e4ac93341447311f4eecb981f8fe34 --- scripts/category.py | 115 +++++++++++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 33 deletions(-) diff --git a/scripts/category.py b/scripts/category.py index 99f918b780..8794727614 100755 --- a/scripts/category.py +++ b/scripts/category.py @@ -472,16 +472,25 @@ def __init__(self, generator, newcat=None, self.comment = comment @staticmethod - def sorted_by_last_name(catlink, pagelink) -> pywikibot.Page: - """Return a Category with key that sorts persons by their last name. + def sorted_by_last_name(catlink: pywikibot.Page, + pagelink: pywikibot.Page) -> pywikibot.Page: + """Return a category entry for a person, sorted by last name. - Parameters: catlink - The Category to be linked. - pagelink - the Page to be placed in the category. + If the page title contains a disambiguation suffix in brackets, + it will be removed. The last word of the (cleaned) title is + treated as the surname and moved to the front, separated by a + comma. - Trailing words in brackets will be removed. Example: If - category_name is 'Author' and pl is a Page to [[Alexandre Dumas - (senior)]], this function will return this Category: - [[Category:Author|Dumas, Alexandre]]. + Example: + If *catlink* is ``Category:Author`` and *pagelink* points to + ``[[Alexandre Dumas (senior)]]``, this method returns:: + + [[Category:Author|Dumas, Alexandre]] + + :param catlink: Category page where the entry should be added. + :param pagelink: Page of the person to be categorized. + :return: A page object representing the category entry with the + correct sort key. """ page_name = pagelink.title() site = pagelink.site @@ -1323,49 +1332,89 @@ class CategoryTreeRobot: """Robot to create tree overviews of the category structure. - Parameters: - * cat_title - The category which will be the tree's root. - * cat_db - A CategoryDatabase object. - * max_depth - The limit beyond which no subcategories will be listed. - This also guarantees that loops in the category structure - won't be a problem. - * filename - The textfile where the tree should be saved; None to print - the tree to stdout. + This class generates a hierarchical overview of categories starting + from a given root category. The tree can be printed to stdout or + written to a file. Cycles in the category structure are prevented + by limiting the depth. + + Example: + Create a tree view of ``Category:Physics`` up to 5 levels deep + and save it to ``physics_tree.txt``:: + + db = CategoryDatabase() + robot = CategoryTreeRobot( + 'Physics', db, 'physics_tree.txt', max_depth=5) + + .. versionchanged:: 10.4 + *max_depth* is keyword only. + + :param cat_title: The category that serves as the root of the + tree. + :param cat_db: A :class:`CategoryDatabase` object + providing access to category data. + :param filename: Path to the text file where the tree + should be saved. If ``None``, the user will be prompted to enter + a filename. If an empty string is entered, the tree will be + printed to stdout. Relative paths are converted to absolute + paths using :meth:`config.datafilepath`. + :param max_depth: Maximum depth of subcategories to traverse. + Prevents infinite loops. """ def __init__( self, - cat_title, - cat_db, - filename=None, + cat_title: str, + cat_db: CategoryDatabase, + filename: str | None = None, + *, max_depth: int = 10 ) -> None: """Initializer.""" - self.cat_title = cat_title or \ - pywikibot.input( + self.cat_title = cat_title \ + or pywikibot.input( 'For which category do you want to create a tree view?') self.cat_db = cat_db if filename is None: filename = pywikibot.input( 'Please enter the name of the file ' 'where the tree should be saved,\n' - 'or press enter to simply show the tree:') + 'or press enter to simply show the tree:' + ) if filename and not os.path.isabs(filename): filename = config.datafilepath(filename) self.filename = filename self.max_depth = max_depth self.site = pywikibot.Site() - def treeview(self, cat, current_depth: int = 0, parent=None) -> str: - """Return a tree view of all subcategories of cat. - - The multi-line string contains a tree view of all subcategories of cat, - up to level max_depth. Recursively calls itself. - - Parameters: - * cat - the Category of the node we're currently opening. - * current_depth - the current level in the tree (for recursion). - * parent - the Category of the category we're coming from. + def treeview(self, + cat: pywikibot.Category, + current_depth: int = 0, + *, + parent: pywikibot.Category | None = None) -> str: + """Return a tree view of subcategories as a multi-line string. + + Generates a hierarchical tree view of all subcategories of the + given category *cat*, up to the depth specified by + ``self.max_depth``. This method is recursive. + + .. versionchanged:: 10.4 + *parent* is keyword only. + + Example: + To get a tree view of ``Category:Physics`` starting at depth 0:: + + cat = pywikibot.Category(site, 'Physics') + tree = robot.treeview(cat) + + :param cat: The Category object currently being expanded in the + tree. + :param current_depth: Current depth level in the tree (used for + recursion). + :param parent: The parent Category from which we descended (to + avoid cycles). + :return: A multi-line string representing the tree structure, + including the number of pages in each category and links to + supercategories. """ result = '#' * current_depth if current_depth > 0: @@ -1680,7 +1729,7 @@ def main(*args: str) -> None: gen_factory.namespaces, summary) elif action == 'tree': bot = CategoryTreeRobot(options.get('from'), cat_db, - options.get('to'), depth) + options.get('to'), max_depth=depth) elif action == 'listify': bot = CategoryListifyRobot(options.get('from'), options.get('to'), summary, From 6762d71d6032de2a1d7bffcc88f3e654f6a29433 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 1 Sep 2025 14:41:48 +0200 Subject: [PATCH 175/279] Update git submodules * Update scripts/i18n from branch 'master' to b3373ad5d79b2d16c209b3bd0846b7bbb3a782f7 - Localisation updates from https://translatewiki.net. Change-Id: I8392c423a1295571c8ab334a08368eef0468ac4f --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index b725db5250..b3373ad5d7 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit b725db5250449bf3a353a60e626af38cc87d9f6e +Subproject commit b3373ad5d79b2d16c209b3bd0846b7bbb3a782f7 From a842b99c2d954647f6b5a2a5237818dc0408a663 Mon Sep 17 00:00:00 2001 From: derich Date: Thu, 28 Aug 2025 10:11:32 +0000 Subject: [PATCH 176/279] handle uncommon uri schemes on weblinkchecker Bug: T389008 Change-Id: I85013d27954842a6dc733b336176e1966e2953c7 --- scripts/weblinkchecker.py | 17 ++++++++++++----- tests/weblinkchecker_tests.py | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) create mode 100755 tests/weblinkchecker_tests.py diff --git a/scripts/weblinkchecker.py b/scripts/weblinkchecker.py index e4bca2a42f..122e02cf37 100755 --- a/scripts/weblinkchecker.py +++ b/scripts/weblinkchecker.py @@ -169,6 +169,9 @@ # Ignore links containing * in domain name # as they are intentionally fake re.compile(r'https?\:\/\/\*(/.*)?'), + + # properly formatted mailto links: no further checking possible + re.compile(r'mailto:[^@]+@[a-z0-9\.]+(\?.*)?'), ] @@ -251,7 +254,8 @@ class LinkCheckThread(threading.Thread): hosts: dict[str, float] = {} lock = threading.Lock() - def __init__(self, page, url, history, http_ignores, day) -> None: + def __init__(self, page, url: str, history: History, + http_ignores: list[int], day: int) -> None: """Initializer.""" self.page = page self.url = url @@ -341,7 +345,8 @@ class History: } """ - def __init__(self, report_thread, site=None) -> None: + def __init__(self, report_thread: DeadLinkReportThread | None, + site: pywikibot._BaseSite | None = None) -> None: """Initializer.""" self.report_thread = report_thread if not site: @@ -539,7 +544,8 @@ class WeblinkCheckerRobot(SingleSiteBot, ExistingPageBot): use_redirects = False - def __init__(self, http_ignores=None, day: int = 7, **kwargs) -> None: + def __init__(self, http_ignores: list[int] | None = None, + day: int = 7, **kwargs) -> None: """Initializer.""" super().__init__(**kwargs) @@ -571,8 +577,9 @@ def treat_page(self) -> None: # thread dies when program terminates thread.daemon = True # use hostname as thread.name - thread.name = removeprefix( - urlparse.urlparse(url).hostname, 'www.') + hostname = urlparse.urlparse(url).hostname + if hostname is not None: + thread.name = removeprefix(hostname, 'www.') self.threads.append(thread) def teardown(self) -> None: diff --git a/tests/weblinkchecker_tests.py b/tests/weblinkchecker_tests.py new file mode 100755 index 0000000000..5a1b9a5146 --- /dev/null +++ b/tests/weblinkchecker_tests.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Tests for the weblinkchecker script.""" +# +# (C) Pywikibot team, 2025 +# +# Distributed under the terms of the MIT license. +# +from __future__ import annotations + +from contextlib import suppress + +import pywikibot +from scripts.weblinkchecker import WeblinkCheckerRobot +from tests.aspects import TestCase, unittest + + +class TestWeblinkchecker(TestCase): + + """Test cases for weblinkchecker.""" + + family = 'wikipedia' + code = 'test' + + def test_different_uri_schemes(self) -> None: + """Test different uri schemes on test page.""" + site = self.get_site('wikipedia:test') + page = pywikibot.Page(site, 'User:DerIch27/weblink test') + generator = [page] + bot = WeblinkCheckerRobot(site=site, generator=generator) + bot.run() + self.assertEqual(1, bot.counter['read']) + + +if __name__ == '__main__': + with suppress(SystemExit): + unittest.main() From 393eadd32d62d820607320699da299cda436838a Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 2 Sep 2025 13:48:19 +0200 Subject: [PATCH 177/279] typing: Update typing from backports Change-Id: I9252a8d1e1a9e28cacad9f422dcf5ab6e30b33b8 --- pywikibot/site/_apisite.py | 3 +-- pywikibot/site/_extensions.py | 21 +++++++++++--------- pywikibot/tools/djvu.py | 7 ++++--- pywikibot/userinterfaces/buffer_interface.py | 5 +++-- scripts/archivebot.py | 4 ++-- scripts/checkimages.py | 4 ++-- scripts/misspelling.py | 4 ++-- scripts/redirect.py | 5 +++-- scripts/solve_disambiguation.py | 2 +- 9 files changed, 30 insertions(+), 25 deletions(-) diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py index 34550dd392..c52c2ba439 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -9,7 +9,6 @@ import datetime import re import time -import typing import webbrowser from collections import OrderedDict, defaultdict from contextlib import suppress @@ -1068,7 +1067,7 @@ def months_names(self) -> list[tuple[str, str]]: return self._months_names - def list_to_text(self, args: typing.Iterable[str]) -> str: + def list_to_text(self, args: Iterable[str]) -> str: """Convert a list of strings into human-readable text. The MediaWiki messages 'and' and 'word-separator' are used as diff --git a/pywikibot/site/_extensions.py b/pywikibot/site/_extensions.py index 66d342b344..7e8191d4d1 100644 --- a/pywikibot/site/_extensions.py +++ b/pywikibot/site/_extensions.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Protocol import pywikibot -from pywikibot.backports import Generator +from pywikibot.backports import Generator, Iterable from pywikibot.data import api from pywikibot.echo import Notification from pywikibot.exceptions import ( @@ -328,28 +328,31 @@ class LinterMixin: """APISite mixin for Linter extension.""" @need_extension('Linter') - def linter_pages(self, lint_categories=None, total=None, - namespaces=None, pageids=None, lint_from=None): + def linter_pages( + self, + lint_categories=None, + total: int = None, + namespaces=None, + pageids: str | int | None = None, + lint_from: str | int | None = None + ) -> Iterable[pywikibot.Page]: """Return a generator to pages containing linter errors. :param lint_categories: categories of lint errors :type lint_categories: an iterable that returns values (str), or a pipe-separated string of values. :param total: if not None, yielding this many items in total - :type total: int :param namespaces: only iterate pages in these namespaces :type namespaces: iterable of str or Namespace key, or a single instance of those types. May be a '|' separated list of namespace identifiers. :param pageids: only include lint errors from the specified pageids - :type pageids: an iterable that returns pageids (str or int), or - a comma- or pipe-separated string of pageids (e.g. - '945097,1483753, 956608' or '945097|483753|956608') + :type pageids: an iterable that returns pageids, or a comma- or + pipe-separated string of pageids (e.g. '945097,1483753, + 956608' or '945097|483753|956608') :param lint_from: Lint ID to start querying from - :type lint_from: str representing digit or integer :return: pages with Linter errors. - :rtype: typing.Iterable[pywikibot.Page] """ query = self._generator(api.ListGenerator, type_arg='linterrors', total=total, # Will set lntlimit diff --git a/pywikibot/tools/djvu.py b/pywikibot/tools/djvu.py index 040e30c527..9c37e03014 100644 --- a/pywikibot/tools/djvu.py +++ b/pywikibot/tools/djvu.py @@ -1,6 +1,6 @@ """Wrapper around djvulibre to access djvu files properties and content.""" # -# (C) Pywikibot team, 2015-2024 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -12,13 +12,14 @@ from collections import Counter import pywikibot +from pywikibot.backports import Sequence -def _call_cmd(args, lib: str = 'djvulibre') -> tuple: +def _call_cmd(args: str | Sequence[str], + lib: str = 'djvulibre') -> tuple[bool, str]: """Tiny wrapper around subprocess.Popen(). :param args: same as Popen() - :type args: str or typing.Sequence[string] :param lib: library to be logged in logging messages :return: returns a tuple (res, stdoutdata), where res is True if dp.returncode != 0 else False diff --git a/pywikibot/userinterfaces/buffer_interface.py b/pywikibot/userinterfaces/buffer_interface.py index 2f508c7656..22121e9373 100644 --- a/pywikibot/userinterfaces/buffer_interface.py +++ b/pywikibot/userinterfaces/buffer_interface.py @@ -3,7 +3,7 @@ .. versionadded:: 6.4 """ # -# (C) Pywikibot team, 2021-2024 +# (C) Pywikibot team, 2021-2025 # # Distributed under the terms of the MIT license. # @@ -11,9 +11,10 @@ import logging import queue -from typing import Any, Sequence +from typing import Any from pywikibot import config +from pywikibot.backports import Sequence from pywikibot.logging import INFO, VERBOSE from pywikibot.userinterfaces._interface_base import ABUIC diff --git a/scripts/archivebot.py b/scripts/archivebot.py index 6b761651e6..d0538edab5 100755 --- a/scripts/archivebot.py +++ b/scripts/archivebot.py @@ -203,12 +203,12 @@ from hashlib import md5 from math import ceil from textwrap import fill -from typing import Any, Pattern +from typing import Any from warnings import warn import pywikibot from pywikibot import i18n -from pywikibot.backports import pairwise +from pywikibot.backports import Pattern, pairwise from pywikibot.exceptions import Error, NoPageError from pywikibot.textlib import ( TimeStripper, diff --git a/scripts/checkimages.py b/scripts/checkimages.py index f7e158a5c0..0e178203ef 100755 --- a/scripts/checkimages.py +++ b/scripts/checkimages.py @@ -75,7 +75,7 @@ Welcome messages are imported from :mod:`scripts.welcome` script. """ # -# (C) Pywikibot team, 2006-2024 +# (C) Pywikibot team, 2006-2025 # # Distributed under the terms of the MIT license. # @@ -85,11 +85,11 @@ import re import time from itertools import zip_longest -from typing import Generator import pywikibot from pywikibot import config, i18n from pywikibot import pagegenerators as pg +from pywikibot.backports import Generator from pywikibot.bot import suggest_help from pywikibot.exceptions import ( EditConflictError, diff --git a/scripts/misspelling.py b/scripts/misspelling.py index 12edc75ed7..e976620fc4 100755 --- a/scripts/misspelling.py +++ b/scripts/misspelling.py @@ -20,17 +20,17 @@ given, it starts at the beginning. """ # -# (C) Pywikibot team, 2007-2024 +# (C) Pywikibot team, 2007-2025 # # Distributed under the terms of the MIT license. # from __future__ import annotations from itertools import chain -from typing import Generator import pywikibot from pywikibot import i18n, pagegenerators +from pywikibot.backports import Generator try: diff --git a/scripts/redirect.py b/scripts/redirect.py index a382b4b132..f58b3c1fdf 100755 --- a/scripts/redirect.py +++ b/scripts/redirect.py @@ -71,7 +71,7 @@ ¶ms; """ # -# (C) Pywikibot team, 2004-2024 +# (C) Pywikibot team, 2004-2025 # # Distributed under the terms of the MIT license. # @@ -80,11 +80,12 @@ import datetime from contextlib import suppress from textwrap import fill -from typing import Any, Generator +from typing import Any import pywikibot import pywikibot.data from pywikibot import i18n, pagegenerators, xmlreader +from pywikibot.backports import Generator from pywikibot.bot import ExistingPageBot, OptionHandler, suggest_help from pywikibot.exceptions import ( CircularRedirectError, diff --git a/scripts/solve_disambiguation.py b/scripts/solve_disambiguation.py index 6684e18beb..c2896f7969 100755 --- a/scripts/solve_disambiguation.py +++ b/scripts/solve_disambiguation.py @@ -83,12 +83,12 @@ from contextlib import suppress from itertools import chain from pathlib import Path -from typing import Generator import pywikibot from pywikibot import config from pywikibot import editor as editarticle from pywikibot import i18n, pagegenerators +from pywikibot.backports import Generator from pywikibot.bot import ( HighlightContextOption, ListOption, From 9be20c13e06b477ec932e6e05a04abaafc68fc3c Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 23 Aug 2025 20:00:53 +0200 Subject: [PATCH 178/279] i18n: Refactor twtranslate to unify fallback_prompt handling - Introduced _return_fallback_or_raise function to pre - fallback_prompt is now consistently returned whenever no translation is found, including unknown keys in existing packages - Update docstrings Bug: T326470 Change-Id: I3a930d20422080c170019c7b0da555a3343556cf --- pywikibot/i18n.py | 96 +++++++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/pywikibot/i18n.py b/pywikibot/i18n.py index 685e918413..a7f17de5d3 100644 --- a/pywikibot/i18n.py +++ b/pywikibot/i18n.py @@ -736,13 +736,8 @@ def twtranslate( "Robot: Changer %(descr)s {{PLURAL:num|une page|quelques pages}}.", } - and so on. - - >>> # this code snippet is running in test environment - >>> # ignore test message "tests: max_retries reduced from 15 to 1" >>> import os >>> os.environ['PYWIKIBOT_TEST_QUIET'] = '1' - >>> from pywikibot import i18n >>> i18n.set_messages_package('tests.i18n') >>> # use a dictionary @@ -752,7 +747,7 @@ def twtranslate( >>> str(i18n.twtranslate( ... 'fr', 'test-plural', {'num': 1, 'descr': 'seulement'})) 'Robot: Changer seulement une page.' - >>> # use format strings also outside + >>> # use parameter for plural and format strings outside >>> str(i18n.twtranslate( ... 'fr', 'test-plural', {'num': 10}, only_plural=True ... ) % {'descr': 'seulement'}) @@ -761,73 +756,92 @@ def twtranslate( .. versionchanged:: 8.1 the *bot_prefix* parameter was added. - :param source: When it's a site it's using the lang attribute and otherwise - it is using the value directly. The site object is recommended. - :param twtitle: The TranslateWiki string title, in - format - :param parameters: For passing parameters. It should be a mapping but for - backwards compatibility can also be a list, tuple or a single value. - They are also used for plural entries in which case they must be a - Mapping and will cause a TypeError otherwise. + .. versionchanged:: 10.5 + *fallback_prompt* is now returned whenever no translation is found, + including unknown keys in existing packages. + + :param source: When it's a site it's using the lang attribute and + otherwise it is using the value directly. The site object is + recommended. + :param twtitle: The TranslateWiki string title, in - + format + :param parameters: For passing parameters. It should be a mapping + but for backwards compatibility can also be a list, tuple or a + single value. They are also used for plural entries in which + case they must be a Mapping and will cause a TypeError otherwise. :param fallback: Try an alternate language code :param fallback_prompt: The English message if i18n is not available - :param only_plural: Define whether the parameters should be only applied to - plural instances. If this is False it will apply the parameters also - to the resulting string. If this is True the placeholders must be - manually applied afterwards. + :param only_plural: Define whether the parameters should be only + applied to plural instances. If this is False it will apply the + parameters also to the resulting string. If this is True the + placeholders must be manually applied afterwards. :param bot_prefix: If True, prepend the message with a bot prefix which depends on the ``config.bot_prefix`` setting - :raise IndexError: If the language supports and requires more plurals than - defined for the given translation template. + :raise IndexError: If the language supports and requires more + plurals than defined for the given translation template. + :raise TypeError: If parameters are not a mapping for plural + messages. + :raise ValueError: If parameters are not a mapping but required. + :raise TranslationError: If no translation found and + *fallback_prompt* is None. """ - prefix = get_bot_prefix(source, use_prefix=bot_prefix) - - if not messages_available(): - if fallback_prompt: + def _return_fallback_or_raise() -> str: + """Return formatted fallback_prompt, or raise TranslationError.""" + if fallback_prompt is not None: if parameters and not only_plural: - return fallback_prompt % parameters - return fallback_prompt - + return prefix + fallback_prompt % parameters + return prefix + fallback_prompt raise pywikibot.exceptions.TranslationError( - f'Unable to load messages package {_messages_package_name} for ' - f' bundle {twtitle}\nIt can happen due to lack of i18n submodule ' - f'or files. See {__url__}/i18n' + fill( + f'No translation available for key {twtitle} of ' + f'{_messages_package_name} package in language ' + f'{getattr(source, "lang", source)}. It can happen due to an ' + f'outdated or missing i18n submodule or files. ' + f'See {__url__}/i18n.' + ) ) - # if source is a site then use its lang attribute, otherwise it's a str + # Get the bot prefix, if requested + prefix = get_bot_prefix(source, use_prefix=bot_prefix) + + # If the messages package isn't available at all, use fallback_prompt + if not messages_available(): + return _return_fallback_or_raise() + + # Determine language code from source lang = getattr(source, 'lang', source) - # There are two possible failure modes: the translation dict might not have - # the language altogether, or a specific key could be untranslated. Both - # modes are caught with the KeyError. + # Prepare list of languages to try; fallback adds alternatives and English langs = [lang] if fallback: langs += [*_altlang(lang), 'en'] + + # Try each language until a translation is found for alt in langs: trans = _get_translation(alt, twtitle) if trans: break else: - raise pywikibot.exceptions.TranslationError(fill( - 'No {} translation has been defined for TranslateWiki key "{}". ' - 'It can happen due to lack of i18n submodule or files or an ' - 'outdated submodule. See {}/i18n' - .format('English' if 'en' in langs else f"'{lang}'", - twtitle, __url__))) + # No translation found: return fallback_prompt if available + return _return_fallback_or_raise() + # Handle plural forms if present if '{{PLURAL:' in trans: - # _extract_plural supports in theory non-mappings, but they are - # deprecated if not isinstance(parameters, Mapping): raise TypeError('parameters must be a mapping.') trans = _extract_plural(alt, trans, parameters) + # Validate parameters type for string formatting if parameters is not None and not isinstance(parameters, Mapping): raise ValueError( f'parameters should be a mapping, not {type(parameters).__name__}' ) + # Apply string formatting if requested if not only_plural and parameters: trans = trans % parameters + + # Return the final translation with bot prefix return prefix + trans From 0b734df9f69516a92ea10c1e6fdadbfd40b5dce7 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 7 Sep 2025 14:39:15 +0200 Subject: [PATCH 179/279] mypy: test config.py with pre-commit Change-Id: I2a25f99dbc413f4f0bdc3f110c01fa4f0dbd5f2a --- .pre-commit-config.yaml | 4 ++-- conftest.py | 2 +- pywikibot/config.py | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index abfaca0cb8..dfbfd77ccc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.11 + rev: v0.12.12 hooks: - id: ruff-check alias: ruff @@ -122,7 +122,7 @@ repos: # Test for files which already passed in past. # They should be also used in conftest.py to exclude them from non-voting mypy test. files: > - ^pywikibot/(__metadata__|echo|exceptions|fixes|time)\.py$| + ^pywikibot/(__metadata__|config|echo|exceptions|fixes|time)\.py$| ^pywikibot/(comms|data|families|specialbots)/__init__\.py$| ^pywikibot/families/[a-z][a-z\d]+_family\.py$| ^pywikibot/page/(__init__|_decorators|_revision)\.py$| diff --git a/conftest.py b/conftest.py index 830c04caba..ebc602704e 100644 --- a/conftest.py +++ b/conftest.py @@ -16,7 +16,7 @@ EXCLUDE_PATTERN = re.compile( r'(?:' - r'(__metadata__|echo|exceptions|fixes|time)|' + r'(__metadata__|config|echo|exceptions|fixes|time)|' r'(comms|data|families|specialbots)/__init__|' r'families/[a-z][a-z\d]+_family|' r'page/(__init__|_decorators|_revision)|' diff --git a/pywikibot/config.py b/pywikibot/config.py index 113e766b56..e322f355a3 100644 --- a/pywikibot/config.py +++ b/pywikibot/config.py @@ -33,7 +33,7 @@ default. Editor detection functions were moved to :mod:`editor`. """ # -# (C) Pywikibot team, 2003-2024 +# (C) Pywikibot team, 2003-2025 # # Distributed under the terms of the MIT license. # @@ -937,7 +937,8 @@ def shortpath(path: str) -> str: _filestatus = os.stat(_filename) _filemode = _filestatus[0] _fileuid = _filestatus[4] - if not OSWIN32 and _fileuid not in [os.getuid(), 0]: + if not OSWIN32 \ + and _fileuid not in [os.getuid(), 0]: # type: ignore[attr-defined] warning(f'Skipped {_filename!r}: owned by someone else.') elif OSWIN32 or _filemode & 0o02 == 0: with open(_filename, 'rb') as f: From d3ca105d895d9e08ab30bae98d475931c8df3f55 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 7 Sep 2025 15:03:17 +0200 Subject: [PATCH 180/279] Test: run pre-commit with Windows and MacOS Change-Id: Ibdcf6962b11afece035f0fac0b3f7a59893b9087 --- .github/workflows/pre-commit.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 1e00a7da77..5a5282a884 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -17,13 +17,20 @@ env: jobs: pre-commit: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os || 'ubuntu-latest' }} strategy: fail-fast: false matrix: python-version: - 3.14-dev - 3.15-dev + include: + - python-version: '3.9' + os: windows-latest + - python-version: '3.13' + os: windows-latest + - python-version: '3' + os: macOS-latest steps: - name: set up python ${{ matrix.python-version }} if: "!endsWith(matrix.python-version, '-dev')" From 253acbdc22cfde2c7b73d3882adc73bec473e850 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 7 Sep 2025 15:39:57 +0200 Subject: [PATCH 181/279] Test: run pre-commit Python 3.9 on MacOS Change-Id: Ib668a378aabeb277e87ece855113bab9393a67fc --- .github/workflows/pre-commit.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 5a5282a884..9ebe6c0bf3 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -22,15 +22,14 @@ jobs: fail-fast: false matrix: python-version: - - 3.14-dev - - 3.15-dev + - '3.9' + - '3.13' + os: + - windows-latest + - macOS-latest include: - - python-version: '3.9' - os: windows-latest - - python-version: '3.13' - os: windows-latest - - python-version: '3' - os: macOS-latest + - python-version: 3.14-dev + - python-version: 3.15-dev steps: - name: set up python ${{ matrix.python-version }} if: "!endsWith(matrix.python-version, '-dev')" From 43ec99fbc1a0a8c517034223069c01afc7560da9 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 27 Feb 2023 18:02:13 +0100 Subject: [PATCH 182/279] [bugfix] Get a token for private wiki Bug: T328814 Change-Id: I9085b292e0f730e152e20686d8800805227eec65 --- pywikibot/login.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pywikibot/login.py b/pywikibot/login.py index 42e41ab7d1..1dcb5039b6 100644 --- a/pywikibot/login.py +++ b/pywikibot/login.py @@ -454,14 +454,16 @@ def login_to_site(self) -> None: if status in ('NeedToken', 'WrongToken', 'badtoken'): # if incorrect login token was used, # force relogin and generate fresh one - pywikibot.error('Received incorrect login token. ' - 'Forcing re-login.') + pywikibot.error(f'{status}: Received incorrect login token.' + ' Forcing re-login.') # invalidate superior wiki cookies (T224712) pywikibot.data.api._invalidate_superior_cookies( self.site.family) - self.site.tokens.clear() - login_request[ - self.keyword('token')] = self.site.tokens['login'] + token = response.get('token') + if not token: + self.site.tokens.clear() + token = self.site.tokens['login'] + login_request[self.keyword('token')] = token continue if status == 'UI': # pragma: no cover From 7a9d56d53bad2609f1a65c61c49741b09395d11f Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 9 Sep 2025 10:14:24 +0200 Subject: [PATCH 183/279] [IMPR] use Site.has_group() to determine the botflag Change-Id: I51e5c8c00d3f20e985de12dbd7afa77e1ab25eff --- scripts/create_isbn_edition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/create_isbn_edition.py b/scripts/create_isbn_edition.py index 07d7d287cf..e788265eb3 100755 --- a/scripts/create_isbn_edition.py +++ b/scripts/create_isbn_edition.py @@ -1561,7 +1561,7 @@ def main(*args: str) -> None: f'{pywikibot.__version__}, {pgmlic}, {creator}') # This script requires a bot flag - wdbotflag = 'bot' in pywikibot.User(repo, repo.user()).groups() + wdbotflag = repo.has_group('bot') # Prebuilt targets target_author = pywikibot.ItemPage(repo, AUTHORINSTANCE) From 0f959cd84b96fb7b556355fad89d0bb96630ae16 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 10 Sep 2025 18:29:00 +0200 Subject: [PATCH 184/279] Tests: Remove kb.mozillazine.org from test matrix Bug: T404217 Change-Id: I63338ea24212439fe0e9acaeb4dfcbba3bcc27a3 --- tests/site_detect_tests.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/site_detect_tests.py b/tests/site_detect_tests.py index 8d3ddc8047..4d81e8fd4b 100755 --- a/tests/site_detect_tests.py +++ b/tests/site_detect_tests.py @@ -61,10 +61,7 @@ class MediaWikiSiteTestCase(SiteDetectionTestCase): standard_version_sites = ( 'http://www.ck-wissen.de/ckwiki/index.php?title=$1', 'http://en.citizendium.org/wiki/$1', - # Server that hosts www.wikichristian.org is unreliable - it - # occasionally responding with 500 error (see: T151368). 'http://www.wikichristian.org/index.php?title=$1', - 'http://kb.mozillazine.org/$1' # 1.40.1 ) non_standard_version_sites = ( From b15a8172671640c0ec077cacafc552f4c43c5e15 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 11 Sep 2025 14:22:17 +0200 Subject: [PATCH 185/279] Update git submodules * Update scripts/i18n from branch 'master' to e9b061ed17d15ba4667efede6ef8db38e83f5574 - Localisation updates from https://translatewiki.net. Change-Id: I2cdc5cc8bd6668572d5525f705c082e1ea236ce5 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index b3373ad5d7..e9b061ed17 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit b3373ad5d79b2d16c209b3bd0846b7bbb3a782f7 +Subproject commit e9b061ed17d15ba4667efede6ef8db38e83f5574 From 4c71b715eadcc2ed8b6658ff6b040ea1844caf1f Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 13 Sep 2025 12:18:32 +0200 Subject: [PATCH 186/279] doc: Update ROADMAP.rst Change-Id: I48695eaeb177ebaebc0417271a8156a197c401a7 --- ROADMAP.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index b9594757e8..721ed5a1cb 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,7 +1,11 @@ Current Release Changes ======================= -* (no changes yet) +* i18n Updates +* Use 'login' token from API response in :meth:`login.ClientLoginManager.login_to_site` + (:phab:`T328814`) +* Always use *fallback_prompt* in :func:`i18n.twtranslate` whenever no + translation is found, including unknown keys in existing packages (:phab:`T326470`) Deprecations From 37e669d440f89389292940697ee606609a9e7852 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 13 Sep 2025 14:22:08 +0200 Subject: [PATCH 187/279] Refactor Siteinfo class - Add type hints - Update docstrings and remove pre-1.12 hints - Simplify _post_process Change-Id: If7cda4be511fc86e6102fb8c59e0b65417fb48e5 --- pywikibot/site/_siteinfo.py | 88 ++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/pywikibot/site/_siteinfo.py b/pywikibot/site/_siteinfo.py index 3f79f3b174..389947a0a5 100644 --- a/pywikibot/site/_siteinfo.py +++ b/pywikibot/site/_siteinfo.py @@ -1,6 +1,6 @@ """Objects representing site info data contents.""" # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -11,13 +11,17 @@ import re from collections.abc import Container from contextlib import suppress -from typing import Any +from typing import TYPE_CHECKING, Any, Literal import pywikibot from pywikibot.exceptions import APIError from pywikibot.tools.collections import EMPTY_DEFAULT +if TYPE_CHECKING: + from pywikibot.site import APISite + + class Siteinfo(Container): """A 'dictionary' like container for siteinfo. @@ -57,48 +61,58 @@ class Siteinfo(Container): ], } - def __init__(self, site) -> None: - """Initialise it with an empty cache.""" + def __init__(self, site: APISite) -> None: + """Initialize Siteinfo for a given site with an empty cache.""" self._site = site - self._cache: dict[str, Any] = {} + self._cache: dict[str, + tuple[Any, datetime.datetime | Literal[False]]] = {} def clear(self) -> None: - """Remove all items from Siteinfo. + """Clear all cached siteinfo properties. .. versionadded:: 7.1 """ self._cache.clear() @staticmethod - def _post_process(prop, data) -> None: - """Do some default handling of data. + def _post_process(prop: str, + data: dict[str, Any] | list[dict[str, Any]]) -> None: + """Convert empty-string boolean properties to actual booleans. - Directly modifies data. + Modifies *data* in place. + + :param prop: The siteinfo property name (e.g., 'general', + 'namespaces', 'magicwords') + :param data: The raw data returned from the server """ # Be careful with version tests inside this here as it might need to # query this method to actually get the version number # Convert boolean props from empty strings to actual boolean values - if prop in Siteinfo.BOOLEAN_PROPS: - # siprop=namespaces and - # magicwords has properties per item in result - if prop in ('namespaces', 'magicwords'): - for index, value in enumerate(data): - # namespaces uses a dict, while magicwords uses a list - key = index if isinstance(data, list) else value - for p in Siteinfo.BOOLEAN_PROPS[prop]: - data[key][p] = p in data[key] + if prop not in Siteinfo.BOOLEAN_PROPS: + return + + bool_props = Siteinfo.BOOLEAN_PROPS[prop] + if prop == 'general': + # Direct properties of 'general' + for p in bool_props: + data[p] = p in data + else: + # 'namespaces' (dict) or 'magicwords' (list of dicts) + items: list[dict[str, Any]] + if isinstance(data, dict): + items = list(data.values()) + elif isinstance(data, list): + items = data else: - for p in Siteinfo.BOOLEAN_PROPS[prop]: - data[p] = p in data + return # unexpected format - def _get_siteinfo(self, prop, expiry) -> dict: - """Retrieve a siteinfo property. + for item in items: + for p in bool_props: + item[p] = p in item - All properties which the site doesn't - support contain the default value. Because pre-1.12 no data was - returned when a property doesn't exists, it queries each property - independently if a property is invalid. + def _get_siteinfo(self, prop, expiry) -> dict: + """Retrieve one or more siteinfo properties from the server. .. seealso:: :api:Siteinfo @@ -110,6 +124,8 @@ def _get_siteinfo(self, prop, expiry) -> dict: the dictionary is a tuple of the value and a boolean to save if it is the default value. """ + invalid_properties: list[str] = [] + def warn_handler(mod, message) -> bool: """Return True if the warning is handled.""" matched = Siteinfo.WARNING_REGEX.fullmatch(message) @@ -119,11 +135,11 @@ def warn_handler(mod, message) -> bool: return True return False - props = [prop] if isinstance(prop, str) else prop + # Convert to list for consistent iteration + props = [prop] if isinstance(prop, str) else list(prop) if not props: raise ValueError('At least one property name must be provided.') - invalid_properties: list[str] = [] request = self._site._request( expiry=pywikibot.config.API_config_expiry if expiry is False else expiry, @@ -134,6 +150,7 @@ def warn_handler(mod, message) -> bool: # warnings are handled later request._warning_handler = warn_handler + try: data = request.submit() except APIError as e: @@ -158,6 +175,7 @@ def warn_handler(mod, message) -> bool: pywikibot.log("Unable to get siprop(s) '{}'" .format("', '".join(invalid_properties))) + # Process valid properties if 'query' in data: # If the request is a CachedRequest, use the _cachetime attr. cache_time = getattr( @@ -169,8 +187,16 @@ def warn_handler(mod, message) -> bool: return result @staticmethod - def _is_expired(cache_date, expire): - """Return true if the cache date is expired.""" + def _is_expired(cache_date: datetime.datetime | Literal[False] | None, + expire: datetime.timedelta | Literal[False]) -> bool: + """Return true if the cache date is expired. + + :param cache_date: The timestamp when the value was cached, or + False if default, None if never. + :param expire: Expiry period as timedelta, or False to never + expire. + :return: True if expired, False otherwise. + """ if isinstance(expire, bool): return expire @@ -215,8 +241,10 @@ def _get_general(self, key: str, expiry): self._cache[prop] = default_info[prop] if key in default_info: return default_info[key] + if key in self._cache['general'][0]: return self._cache['general'][0][key], self._cache['general'] + return None def __getitem__(self, key: str): From 315c6af7d49f9b14c302a4375c2570d0288502ad Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 14 Sep 2025 16:22:55 +0200 Subject: [PATCH 188/279] IMPR: Use a better message in TokenWallet.update_tokens for KeyError - raise KeyError with 'No valid token types found to update.' instead of 'Invalid token None for user on .' This also solves mypy issues for this module. - update pre-commit hooks - update mypy tests Change-Id: Ic19b041300499cb546b91bcac4e85e2919568c31 --- .pre-commit-config.yaml | 6 +++--- conftest.py | 3 ++- pywikibot/site/_tokenwallet.py | 18 ++++++++++++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dfbfd77ccc..090c22d965 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.12 + rev: v0.13.0 hooks: - id: ruff-check alias: ruff @@ -113,7 +113,7 @@ repos: - flake8-tuple>=0.4.1 - pep8-naming>=0.15.1 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.17.1 + rev: v1.18.1 hooks: - id: mypy args: @@ -127,6 +127,6 @@ repos: ^pywikibot/families/[a-z][a-z\d]+_family\.py$| ^pywikibot/page/(__init__|_decorators|_revision)\.py$| ^pywikibot/scripts/(?:i18n/)?__init__\.py$| - ^pywikibot/site/(__init__|_basesite|_decorators|_extensions|_interwikimap|_upload)\.py$| + ^pywikibot/site/(__init__|_basesite|_decorators|_extensions|_interwikimap|_tokenwallet|_upload)\.py$| ^pywikibot/tools/(_logging|_unidata|formatter)\.py$| ^pywikibot/userinterfaces/(__init__|_interface_base|terminal_interface)\.py$ diff --git a/conftest.py b/conftest.py index ebc602704e..27f24800da 100644 --- a/conftest.py +++ b/conftest.py @@ -21,7 +21,8 @@ r'families/[a-z][a-z\d]+_family|' r'page/(__init__|_decorators|_revision)|' r'scripts/(i18n/)?__init__|' - r'site/(__init__|_basesite|_decorators|_extensions|_interwikimap|_upload)|' + r'site/(__init__|_basesite|_decorators|_extensions|_interwikimap|' + r'_tokenwallet|_upload)|' r'tools/(_logging|_unidata|formatter)|' r'userinterfaces/(__init__|_interface_base|terminal_interface)' r')\.py' diff --git a/pywikibot/site/_tokenwallet.py b/pywikibot/site/_tokenwallet.py index 0baabdecd2..a6911999cf 100644 --- a/pywikibot/site/_tokenwallet.py +++ b/pywikibot/site/_tokenwallet.py @@ -1,6 +1,6 @@ """Objects representing api tokens.""" # -# (C) Pywikibot team, 2008-2023 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -119,11 +119,25 @@ def update_tokens(self, tokens: list[str]) -> list[str]: r._params['token'] = r.site.tokens.update_tokens(r._params['token']) .. versionadded:: 8.0 + + :param tokens: A list of token types that need to be updated. + :return: A list of updated tokens corresponding to the given + *tokens* types. + :raises KeyError: If no valid token types can be determined to + update. """ # find the token types types = [key for key, value in self._tokens.items() for token in tokens - if value == token] or [self._last_token_key] + if value == token] + + # fallback to _last_token_key if no types found + if not types and self._last_token_key is not None: + types = [self._last_token_key] + + if not types: + raise KeyError('No valid token types found to update.') + self.clear() # clear the cache return [self[token_type] for token_type in types] From df8efb3d00060f2f4b718293ef8064aa752cdc9c Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 13 Sep 2025 20:11:43 +0200 Subject: [PATCH 189/279] IMPR: use (cached) siteinfo with ChangeLangBot Change-Id: I490d8f1632c9aa6c3fc21c8cc12bf8e47ede8af7 --- scripts/change_pagelang.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/scripts/change_pagelang.py b/scripts/change_pagelang.py index 587fce7ec1..f6f327be2c 100755 --- a/scripts/change_pagelang.py +++ b/scripts/change_pagelang.py @@ -23,7 +23,7 @@ .. versionadded:: 5.1 """ # -# (C) Pywikibot team, 2018-2024 +# (C) Pywikibot team, 2018-2025 # # Distributed under the terms of the MIT license. # @@ -80,17 +80,19 @@ def treat(self, page) -> None: :type page: pywikibot.page.BasePage """ # Current content language of the page and site language - parameters = {'action': 'query', - 'prop': 'info', - 'titles': page.title(), - 'meta': 'siteinfo'} + parameters = { + 'action': 'query', + 'prop': 'info', + 'titles': page.title(), + } r = self.site.simple_request(**parameters) langcheck = r.submit()['query'] currentlang = '' for k in langcheck['pages']: currentlang = langcheck['pages'][k]['pagelanguage'] - sitelang = langcheck['general']['lang'] + + sitelang = self.site.siteinfo['lang'] if self.opt.setlang == currentlang: pywikibot.info( @@ -109,7 +111,7 @@ def treat(self, page) -> None: choice = pywikibot.input_choice( f'The content language for this page is already set to ' f'<>{currentlang}<>, which is different from ' - f'the default ({sitelang}). Change it to' + f'the default ({sitelang}). Change it to ' f'<>{self.opt.setlang}<> anyway?', [('Always', 'a'), ('Yes', 'y'), ('No', 'n'), ('Never', 'v')], default='Y') From 61af16998fce6cce6ff3ff66ea38a2b56375c341 Mon Sep 17 00:00:00 2001 From: derich Date: Thu, 4 Sep 2025 09:06:22 +0000 Subject: [PATCH 190/279] fix some typing errors raised by mypy Also rename module parameter of ParamInfo.parameter() method to module_name, deprecate the old name and fix its call in change_pagelang.py script. Also update mypy settings for fixed issues. Change-Id: I1f30de93e8cc7b99f07d46dc4d7fbb06a158f67d --- .pre-commit-config.yaml | 3 ++- conftest.py | 3 ++- make_dist.py | 2 +- pywikibot/comms/http.py | 6 +++--- pywikibot/config.py | 6 +++--- pywikibot/cosmetic_changes.py | 2 +- pywikibot/data/api/_paraminfo.py | 18 ++++++++++++------ pywikibot/data/mysql.py | 4 ++-- pywikibot/logging.py | 6 +++--- pywikibot/pagegenerators/__init__.py | 6 ++++-- pywikibot/site/_datasite.py | 2 +- pywikibot/site/_generators.py | 6 +++--- .../userinterfaces/terminal_interface_base.py | 12 ++++++------ scripts/change_pagelang.py | 2 +- 14 files changed, 44 insertions(+), 34 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 090c22d965..3ceb417357 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -122,10 +122,11 @@ repos: # Test for files which already passed in past. # They should be also used in conftest.py to exclude them from non-voting mypy test. files: > - ^pywikibot/(__metadata__|config|echo|exceptions|fixes|time)\.py$| + ^pywikibot/(__metadata__|config|echo|exceptions|fixes|logging|time)\.py$| ^pywikibot/(comms|data|families|specialbots)/__init__\.py$| ^pywikibot/families/[a-z][a-z\d]+_family\.py$| ^pywikibot/page/(__init__|_decorators|_revision)\.py$| + ^pywikibot/pagegenerators/__init__\.py$| ^pywikibot/scripts/(?:i18n/)?__init__\.py$| ^pywikibot/site/(__init__|_basesite|_decorators|_extensions|_interwikimap|_tokenwallet|_upload)\.py$| ^pywikibot/tools/(_logging|_unidata|formatter)\.py$| diff --git a/conftest.py b/conftest.py index 27f24800da..c49444b13b 100644 --- a/conftest.py +++ b/conftest.py @@ -16,10 +16,11 @@ EXCLUDE_PATTERN = re.compile( r'(?:' - r'(__metadata__|config|echo|exceptions|fixes|time)|' + r'(__metadata__|config|echo|exceptions|fixes|logging|time)|' r'(comms|data|families|specialbots)/__init__|' r'families/[a-z][a-z\d]+_family|' r'page/(__init__|_decorators|_revision)|' + r'pagegenerators/__init__|' r'scripts/(i18n/)?__init__|' r'site/(__init__|_basesite|_decorators|_extensions|_interwikimap|' r'_tokenwallet|_upload)|' diff --git a/make_dist.py b/make_dist.py index c2a519e6cd..238928ac65 100755 --- a/make_dist.py +++ b/make_dist.py @@ -247,7 +247,7 @@ def cleanup(self) -> None: # pragma: no cover info('<>done') -def handle_args() -> tuple[bool, bool, bool, bool]: +def handle_args() -> tuple[bool, bool, bool, bool, bool]: """Handle arguments and print documentation if requested. :return: Return whether dist is to be installed locally or to be diff --git a/pywikibot/comms/http.py b/pywikibot/comms/http.py index f6d4bc07f4..281cf1693d 100644 --- a/pywikibot/comms/http.py +++ b/pywikibot/comms/http.py @@ -196,7 +196,7 @@ def user_agent_username(username=None): def user_agent(site: pywikibot.site.BaseSite | None = None, - format_string: str = '') -> str: + format_string: str | None = '') -> str: """Generate the user agent string for a given site and format. :param site: The site for which this user agent is intended. May be @@ -211,7 +211,7 @@ def user_agent(site: pywikibot.site.BaseSite | None = None, pywikibot.bot.calledModuleName())) values.update(dict.fromkeys(['family', 'code', 'lang', 'site'], '')) - script_comments = [] + script_comments: list[str] = [] if config.user_agent_description: script_comments.append(config.user_agent_description) @@ -539,7 +539,7 @@ def _try_decode(content: bytes, encoding: str | None) -> str | None: pywikibot.warning( f'Unknown or invalid encoding {encoding!r} for {response.url}') except UnicodeDecodeError as e: - pywikibot.warning(f'{e} found in {content}') + pywikibot.warning(f'{e} found in {content!r}') else: return encoding diff --git a/pywikibot/config.py b/pywikibot/config.py index e322f355a3..ced631c697 100644 --- a/pywikibot/config.py +++ b/pywikibot/config.py @@ -146,7 +146,7 @@ class _ConfigurationDeprecationWarning(UserWarning): # User agent description # This is a free-form string that can be user to describe specific bot/tool, # provide contact information, etc. -user_agent_description = None +user_agent_description: str | None = None # Fake user agent. # Some external websites reject bot-like user agents. It is possible to use # fake user agents in requests to these websites. @@ -225,7 +225,7 @@ class _ConfigurationDeprecationWarning(UserWarning): # use them. In this case, the password file should contain a BotPassword object # in the following format: # (username, BotPassword(botname, botpassword)) -password_file = None +password_file: str | os.PathLike | None = None # edit summary to use if not supplied by bot script # WARNING: this should NEVER be used in practice, ALWAYS supply a more @@ -498,7 +498,7 @@ def register_families_folder(folder_path: str, # transliteration_target = console_encoding # After emitting the warning, this last option will be set. -transliteration_target = None +transliteration_target: str | None = None # The encoding in which textfiles are stored, which contain lists of page # titles. The most used is 'utf-8'; 'utf-8-sig' recognizes BOM. diff --git a/pywikibot/cosmetic_changes.py b/pywikibot/cosmetic_changes.py index f97c7a7988..af94592777 100644 --- a/pywikibot/cosmetic_changes.py +++ b/pywikibot/cosmetic_changes.py @@ -735,7 +735,7 @@ def removeEmptySections(self, text: str) -> str: return text # iterate stripped sections and create a new page body - new_body: textlib.SectionList[textlib.Section] = [] + new_body: textlib.SectionList = textlib.SectionList() for i, strip_section in enumerate(strip_sections): current_dep = sections[i].level try: diff --git a/pywikibot/data/api/_paraminfo.py b/pywikibot/data/api/_paraminfo.py index e9bf0af8da..44fd7c4b69 100644 --- a/pywikibot/data/api/_paraminfo.py +++ b/pywikibot/data/api/_paraminfo.py @@ -12,7 +12,12 @@ import pywikibot from pywikibot import config from pywikibot.backports import Iterable, batched -from pywikibot.tools import classproperty, deprecated, remove_last_args +from pywikibot.tools import ( + classproperty, + deprecated, + deprecated_args, + remove_last_args, +) __all__ = ['ParamInfo'] @@ -48,11 +53,11 @@ def __init__(self, self._paraminfo: dict[str, Any] = {} # Cached data. - self._prefix_map = {} + self._prefix_map: dict[str, str] = {} self._action_modules = frozenset() # top level modules self._modules = {} # filled in _init() (and enlarged in fetch) - self._limit = None + self._limit: int | None = None self._preloaded_modules = self.init_modules if preloaded_modules: @@ -331,9 +336,10 @@ def __len__(self) -> int: """Return number of cached modules.""" return len(self._paraminfo) + @deprecated_args(module='module_name') # since 10.5.0 def parameter( self, - module: str, + module_name: str, param_name: str ) -> dict[str, Any] | None: """Get details about one modules parameter. @@ -345,9 +351,9 @@ def parameter( :return: metadata that describes how the parameter may be used """ try: - module = self[module] + module = self[module_name] except KeyError: - raise ValueError(f"paraminfo for '{module}' not loaded") + raise ValueError(f"paraminfo for '{module_name}' not loaded") try: params = module['parameters'] diff --git a/pywikibot/data/mysql.py b/pywikibot/data/mysql.py index 6193a4dc39..a171b3052a 100644 --- a/pywikibot/data/mysql.py +++ b/pywikibot/data/mysql.py @@ -1,6 +1,6 @@ """Miscellaneous helper functions for mysql queries.""" # -# (C) Pywikibot team, 2016-2022 +# (C) Pywikibot team, 2016-2025 # # Distributed under the terms of the MIT license. # @@ -44,7 +44,7 @@ def mysql_query(query: str, params=None, """ # These are specified in config.py or your user config file if verbose is None: - verbose = config.verbose_output + verbose = config.verbose_output > 0 if config.db_connect_file is None: credentials = {'user': config.db_username, diff --git a/pywikibot/logging.py b/pywikibot/logging.py index 4df2669ba1..c86ac7e7da 100644 --- a/pywikibot/logging.py +++ b/pywikibot/logging.py @@ -24,7 +24,7 @@ - :python:`Logging Cookbook` """ # -# (C) Pywikibot team, 2010-2024 +# (C) Pywikibot team, 2010-2025 # # Distributed under the terms of the MIT license. # @@ -61,7 +61,7 @@ """ _init_routines: list[Callable[[], Any]] = [] -_inited_routines = set() +_inited_routines: set[Callable[[], Any]] = set() def add_init_routine(routine: Callable[[], Any]) -> None: @@ -349,7 +349,7 @@ def exception(msg: Any = None, *args: Any, if msg is None: exc_type, value, _tb = sys.exc_info() msg = str(value) - if not exc_info: + if exc_type is not None and not exc_info: msg += f' ({exc_type.__name__})' assert msg is not None error(msg, *args, exc_info=exc_info, **kwargs) diff --git a/pywikibot/pagegenerators/__init__.py b/pywikibot/pagegenerators/__init__.py index dea213cd1d..58cfb4b86d 100644 --- a/pywikibot/pagegenerators/__init__.py +++ b/pywikibot/pagegenerators/__init__.py @@ -12,7 +12,7 @@ ¶ms; """ # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -594,7 +594,9 @@ def PageWithTalkPageGenerator( if not return_talk_only or page.isTalkPage(): yield page if not page.isTalkPage(): - yield page.toggleTalkPage() + talk_page = page.toggleTalkPage() + if talk_page is not None: + yield talk_page def RepeatingGenerator( diff --git a/pywikibot/site/_datasite.py b/pywikibot/site/_datasite.py index d67f2ba560..cf09ee3018 100644 --- a/pywikibot/site/_datasite.py +++ b/pywikibot/site/_datasite.py @@ -212,7 +212,7 @@ def preload_entities( if not hasattr(self, '_entity_namespaces'): self._cache_entity_namespaces() for batch in batched(pagelist, groupsize): - req = {'ids': [], 'titles': [], 'sites': []} + req: dict[str, list[str]] = {'ids': [], 'titles': [], 'sites': []} for p in batch: if isinstance(p, pywikibot.page.WikibaseEntity): ident = p._defined_by() diff --git a/pywikibot/site/_generators.py b/pywikibot/site/_generators.py index 2d4a7d1840..9ba50f00c0 100644 --- a/pywikibot/site/_generators.py +++ b/pywikibot/site/_generators.py @@ -89,7 +89,7 @@ def load_pages_from_pageids( # Store the order of the input data. priority_dict = dict(zip(batch, range(len(batch)))) - prio_queue = [] + prio_queue: list[tuple[int, pywikibot.Page]] = [] next_prio = 0 params = {'pageids': batch} rvgen = api.PropertyGenerator('info', site=self, parameters=params) @@ -172,7 +172,7 @@ def preloadpages( # Do not use p.pageid property as it will force page loading. pageids = [str(p._pageid) for p in batch if hasattr(p, '_pageid') and p._pageid > 0] - cache = {} + cache: dict[str, tuple[int, pywikibot.Page]] = {} # In case of duplicates, return the first entry. for priority, page in enumerate(batch): try: @@ -181,7 +181,7 @@ def preloadpages( except InvalidTitleError: pywikibot.exception() - prio_queue = [] + prio_queue: list[tuple[int, pywikibot.Page]] = [] next_prio = 0 rvgen = api.PropertyGenerator(props, site=self) rvgen.set_maximum_items(-1) # suppress use of "rvlimit" parameter diff --git a/pywikibot/userinterfaces/terminal_interface_base.py b/pywikibot/userinterfaces/terminal_interface_base.py index f2a07c6e90..6c4c8e38e9 100644 --- a/pywikibot/userinterfaces/terminal_interface_base.py +++ b/pywikibot/userinterfaces/terminal_interface_base.py @@ -11,7 +11,7 @@ import re import sys import threading -from typing import Any, NoReturn +from typing import Any, Literal, NoReturn, TextIO import pywikibot from pywikibot import config @@ -95,7 +95,7 @@ def __init__(self) -> None: def init_handlers( self, root_logger, - default_stream: str = 'stderr' + default_stream: TextIO | Literal['stderr', 'stdout'] = 'stderr' ) -> None: """Initialize the handlers for user output. @@ -536,15 +536,15 @@ def input_list_choice(self, question: str, answers: Sequence[Any], choice = self.input(question, default=default, force=force) try: - choice = int(choice) - 1 + parsedchoice = int(choice) - 1 except (TypeError, ValueError): if choice in answers: return choice - choice = -1 + parsedchoice = -1 # User typed choice number - if 0 <= choice < len(answers): - return answers[choice] + if 0 <= parsedchoice < len(answers): + return answers[parsedchoice] if force: raise ValueError( diff --git a/scripts/change_pagelang.py b/scripts/change_pagelang.py index f6f327be2c..bedd622fbb 100755 --- a/scripts/change_pagelang.py +++ b/scripts/change_pagelang.py @@ -154,7 +154,7 @@ def main(*args: str) -> None: site = pywikibot.Site() specialpages = site.siteinfo['specialpagealiases'] specialpagelist = {item['realname'] for item in specialpages} - allowedlanguages = site._paraminfo.parameter(module='setpagelanguage', + allowedlanguages = site._paraminfo.parameter(module_name='setpagelanguage', param_name='lang')['type'] # Check if the special page PageLanguage is enabled on the wiki # If it is not, page languages can't be set, and there's no point in From 49289b59188653c7178e2434e2edaf79d0af0796 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 14 Sep 2025 18:54:54 +0200 Subject: [PATCH 191/279] tests: use types-requests for mypy tests with tox Also update mypy settings for fixed modules Change-Id: I36237d7ec6eea0c47bde053e44c69c23290aef24 --- .pre-commit-config.yaml | 1 + conftest.py | 1 + tox.ini | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ceb417357..30067cc640 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -124,6 +124,7 @@ repos: files: > ^pywikibot/(__metadata__|config|echo|exceptions|fixes|logging|time)\.py$| ^pywikibot/(comms|data|families|specialbots)/__init__\.py$| + ^pywikibot/data/memento\.py$| ^pywikibot/families/[a-z][a-z\d]+_family\.py$| ^pywikibot/page/(__init__|_decorators|_revision)\.py$| ^pywikibot/pagegenerators/__init__\.py$| diff --git a/conftest.py b/conftest.py index c49444b13b..c009871f47 100644 --- a/conftest.py +++ b/conftest.py @@ -18,6 +18,7 @@ r'(?:' r'(__metadata__|config|echo|exceptions|fixes|logging|time)|' r'(comms|data|families|specialbots)/__init__|' + r'data/memento|' r'families/[a-z][a-z\d]+_family|' r'page/(__init__|_decorators|_revision)|' r'pagegenerators/__init__|' diff --git a/tox.ini b/tox.ini index 396c5f1dbc..a949b3db85 100644 --- a/tox.ini +++ b/tox.ini @@ -64,7 +64,9 @@ deps = [testenv:typing] basepython = python3.9 -deps = pytest-mypy +deps = + pytest-mypy + types-requests commands = mypy --version pytest --mypy -m mypy pywikibot From 9714c828d4eeb65c382e8cbcdc5c8165899a2711 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 7 Sep 2025 13:32:15 +0200 Subject: [PATCH 192/279] 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 --- docs/mwapi.rst | 2 +- pywikibot/data/api/_requests.py | 10 ++- pywikibot/exceptions.py | 19 +++-- pywikibot/page/_basepage.py | 44 +++++++++++ pywikibot/site/_apisite.py | 134 ++++++++++++++++++++++---------- scripts/pyproject.toml | 2 +- scripts/revertbot.py | 15 ++-- tests/site_tests.py | 77 ++++++++++++++++++ 8 files changed, 248 insertions(+), 55 deletions(-) diff --git a/docs/mwapi.rst b/docs/mwapi.rst index f8a9748ae4..10a90e0e93 100644 --- a/docs/mwapi.rst +++ b/docs/mwapi.rst @@ -103,7 +103,7 @@ See the table below for a cross reference between MediaWiki's API and Pywikibot' - * - :api:`rollback` - :meth:`rollbackpage()` - - + - meth:`BasePage.rollback()` - * - :api:`shortenurl` - :meth:`create_short_link()` diff --git a/pywikibot/data/api/_requests.py b/pywikibot/data/api/_requests.py index 07fa07f21a..a49bc53dc0 100644 --- a/pywikibot/data/api/_requests.py +++ b/pywikibot/data/api/_requests.py @@ -532,8 +532,16 @@ def _simulate(self, action): # 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 66ae9b06ea..00f1a269d2 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 @@ class PageRelatedError(Error): 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 @@ def __init__(self, page: pywikibot.page.BasePage, 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 1e7ca3f488..f10c1d1f3f 100644 --- a/pywikibot/page/_basepage.py +++ b/pywikibot/page/_basepage.py @@ -2004,6 +2004,50 @@ def move(self, 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() + ` + + :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 + `. + :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': , + 'pageid': , + 'summary': , + 'revid': , + 'old_revid': , + 'last_revid': , + } + + 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 c52c2ba439..c4f2764f29 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -2505,68 +2505,122 @@ def movepage( # 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. 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 + ` 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': , + 'pageid': , + 'summary': , + 'revid': , + 'old_revid': , + 'last_revid': , + } + + :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 page is not None and pageid is not None: + raise ValueError( + "The parameters 'page' and 'pageid' cannot be used together.") - :param page: the Page to be rolled back (must exist) - :keyword user: the last user to be rollbacked; - default is page.latest_revision.user - """ - if len(page._revisions) < 2: - raise Error( - f'Rollback of {page} aborted; load revision history first.') + 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.') - - parameters = merge_unique_dicts(kwargs, - action='rollback', - title=page, - token=self.tokens['rollback'], - user=user) + params = 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 0886321349..72811b76dc 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -19,7 +19,7 @@ description = "Pywikibot Scripts Collection" 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 64fe498fa1..015871b027 100755 --- a/scripts/revertbot.py +++ b/scripts/revertbot.py @@ -36,12 +36,14 @@ def callback(self, item) -> bool: 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 @@ def revert_contribs(self, callback=None) -> None: 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 @@ def revert(self, item) -> str | bool: 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 8c3df54868..ba06e324f2 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 @@ def test_delete_oldimage(self) -> None: 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.""" From 15f6d2bad62009d7efe9e2eb339691001f62d116 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 18 Sep 2025 14:25:55 +0200 Subject: [PATCH 193/279] Update git submodules * Update scripts/i18n from branch 'master' to feefe6eea35cf5f2af8ee11d10a68e97720d1e5b - Localisation updates from https://translatewiki.net. Change-Id: I2cfe173dc41cbd024a68c7c9e4c4a6f5f81df80c --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index e9b061ed17..feefe6eea3 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit e9b061ed17d15ba4667efede6ef8db38e83f5574 +Subproject commit feefe6eea35cf5f2af8ee11d10a68e97720d1e5b From a24beca7d387e46c36edc254857fd90722f11ffa Mon Sep 17 00:00:00 2001 From: Meno25 Date: Fri, 19 Sep 2025 08:28:31 +0000 Subject: [PATCH 194/279] Add support for new wiki * mswikiquote Bug: T404702 Change-Id: I07b920d4f898e836ffce681246ca451b2731f58d --- pywikibot/families/wikiquote_family.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pywikibot/families/wikiquote_family.py b/pywikibot/families/wikiquote_family.py index 5e59f89f39..3e6fe6af9f 100644 --- a/pywikibot/families/wikiquote_family.py +++ b/pywikibot/families/wikiquote_family.py @@ -33,7 +33,7 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'ca', 'cs', 'cy', 'da', 'de', 'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'gl', 'gor', 'gu', 'guw', 'he', 'hi', 'hr', 'hu', 'hy', 'id', 'ig', 'is', 'it', 'ja', 'ka', 'kn', 'ko', 'ku', 'ky', 'la', 'li', - 'lt', 'ml', 'mr', 'nl', 'nn', 'no', 'pl', 'pt', 'ro', 'ru', 'sa', + 'lt', 'ml', 'mr', 'ms', 'nl', 'nn', 'no', 'pl', 'pt', 'ro', 'ru', 'sa', 'sah', 'sk', 'sl', 'sq', 'sr', 'su', 'sv', 'ta', 'te', 'th', 'tl', 'tr', 'uk', 'ur', 'uz', 'vi', 'zh', } From b271289f8d60c1f925f13e7d2333fecbb2b1de12 Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 11 Sep 2025 13:48:38 +0200 Subject: [PATCH 195/279] Add new property APISite.restrictions The new property also gives 'cascadinglevels' and 'semiprotectedlevels'. It shortens APISite.siteinfo['restrictions'] to APISite.restrictions but the values are sets instead of lists. APISite methods protection_types() and protection_levels() have been deprecated and replaced. This also solves Bug: T404309 Change-Id: I7199e78c6600beb21736031e8f4a4a13a3cc27f4 --- pywikibot/site/_apisite.py | 70 +++++++++++++++++++++++----------- pywikibot/site/_generators.py | 8 ++-- scripts/protect.py | 6 +-- tests/site_generators_tests.py | 2 +- tests/siteinfo_tests.py | 4 +- 5 files changed, 57 insertions(+), 33 deletions(-) diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py index c4f2764f29..627d4d22f2 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -1566,7 +1566,7 @@ def page_can_be_edited( :raises ValueError: invalid action parameter """ - if action not in self.siteinfo.get('restrictions')['types']: + if action not in self.restrictions['types']: raise ValueError( f'{type(self).__name__}.page_can_be_edited(): ' f'Invalid value "{action}" for "action" parameter' @@ -2788,17 +2788,7 @@ def undelete( finally: self.unlock_page(page) - _protect_errors = { - 'noapiwrite': 'API editing not enabled on {site} wiki', - 'writeapidenied': 'User {user} not allowed to edit through the API', - 'permissiondenied': - 'User {user} not authorized to protect pages on {site} wiki.', - 'cantedit': - "User {user} can't protect this page because user {user} " - "can't edit it.", - 'protect-invalidlevel': 'Invalid protection level' - } - + @deprecated("the 'restrictions' property", since='10.5.0') def protection_types(self) -> set[str]: """Return the protection types available on this site. @@ -2808,12 +2798,14 @@ def protection_types(self) -> set[str]: >>> sorted(site.protection_types()) ['create', 'edit', 'move', 'upload'] - .. seealso:: :py:obj:`Siteinfo._get_default()` + .. deprecated:: 10.5 + Use :attr:`restrictions[types]` instead. :return: protection types available """ - return set(self.siteinfo.get('restrictions')['types']) + return self.restrictions['types'] + @deprecated("the 'restrictions' property", since='10.5.0') def protection_levels(self) -> set[str]: """Return the protection levels available on this site. @@ -2823,11 +2815,44 @@ def protection_levels(self) -> set[str]: >>> sorted(site.protection_levels()) ['', 'autoconfirmed', ... 'sysop', 'templateeditor'] - .. seealso:: :py:obj:`Siteinfo._get_default()` + .. deprecated:: 10.5 + Use :attr:`restrictions[levels]` instead. - :return: protection types available + :return: protection levels available + """ + return self.restrictions['levels'] + + @property + def restrictions(self) -> dict[str, set[str]]: + """Return the page restrictions available on this site. + + **Example:** + + >>> site = pywikibot.Site('wikipedia:test') + >>> r = site.restrictions + >>> sorted(r['types']) + ['create', 'edit', 'move', 'upload'] + >>> sorted(r['levels']) + ['', 'autoconfirmed', ... 'sysop', 'templateeditor'] + + .. versionadded:: 10.5 + .. seealso:: :meth:`page_restrictions` + + :return: dict with keys 'types', 'levels', 'cascadinglevels' and + 'semiprotectedlevels', all as sets of strings """ - return set(self.siteinfo.get('restrictions')['levels']) + return {k: set(v) for k, v in self.siteinfo['restrictions'].items()} + + _protect_errors = { + 'noapiwrite': 'API editing not enabled on {site} wiki', + 'writeapidenied': 'User {user} not allowed to edit through the API', + 'permissiondenied': + 'User {user} not authorized to protect pages on {site} wiki.', + 'cantedit': + "User {user} can't protect this page because user {user} " + "can't edit it.", + 'protect-invalidlevel': 'Invalid protection level' + } @need_right('protect') def protect( @@ -2842,15 +2867,14 @@ def protect( .. seealso:: - :meth:`page.BasePage.protect` - - :meth:`protection_types` - - :meth:`protection_levels` + - :attr:`restrictions` + - :meth:`page_restrictions` - :api:`Protect` :param protections: A dict mapping type of protection to - protection level of that type. Refer :meth:`protection_types` - for valid restriction types and :meth:`protection_levels` - for valid restriction levels. If None is given, however, - that protection will be skipped. + protection level of that type. Refer :meth:`restrictions` + for valid restriction types restriction levels. If None is + given, however, that protection will be skipped. :param reason: Reason for the action :param expiry: When the block should expire. This expiry will be applied to all protections. If ``None``, ``'infinite'``, diff --git a/pywikibot/site/_generators.py b/pywikibot/site/_generators.py index 9ba50f00c0..9901495419 100644 --- a/pywikibot/site/_generators.py +++ b/pywikibot/site/_generators.py @@ -2346,7 +2346,7 @@ def redirectpages( """ return self.querypage('Listredirects', total) - @deprecate_arg('type', 'protect_type') + @deprecate_arg('type', 'protect_type') # since 9.0 def protectedpages( self, namespace: NamespaceArgType = 0, @@ -2368,13 +2368,13 @@ def protectedpages( :param namespace: The searched namespace. :param protect_type: The protection type to search for (default 'edit'). - :param level: The protection level (like 'autoconfirmed'). If False it - shows all protection levels. + :param level: The protection level (like 'autoconfirmed'). If + False it shows all protection levels. :return: The pages which are protected. """ namespaces = self.namespaces.resolve(namespace) # always assert, so we are be sure that protect_type could be 'create' - assert 'create' in self.protection_types(), \ + assert 'create' in self.restrictions['types'], \ "'create' should be a valid protection type." if protect_type == 'create': return self._generator( diff --git a/scripts/protect.py b/scripts/protect.py index 4efbbd498f..b462e7a056 100755 --- a/scripts/protect.py +++ b/scripts/protect.py @@ -56,7 +56,7 @@ # # Created by modifying delete.py # -# (C) Pywikibot team, 2008-2023 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -172,11 +172,11 @@ def main(*args: str) -> None: site = pywikibot.Site() generator_type = None - protection_levels = site.protection_levels() + protection_levels = site.restrictions['levels'] if '' in protection_levels: protection_levels.add('all') - protection_types = site.protection_types() + protection_types = site.restrictions['types'] gen_factory = pagegenerators.GeneratorFactory() for arg in local_args: option, sep, value = arg.partition(':') diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index 92e4443684..8b38a9e1ec 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -608,7 +608,7 @@ def test_protectedpages_edit_level(self) -> None: """Test protectedpages protection level.""" site = self.get_site() levels = set() - all_levels = site.protection_levels().difference(['']) + all_levels = site.restrictions['levels'].difference(['']) for level in all_levels: if list(site.protectedpages(protect_type='edit', level=level, total=1)): diff --git a/tests/siteinfo_tests.py b/tests/siteinfo_tests.py index 577b5a670d..4cb7165ce8 100755 --- a/tests/siteinfo_tests.py +++ b/tests/siteinfo_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for the site module.""" # -# (C) Pywikibot team, 2008-2022 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -64,7 +64,7 @@ def test_properties(self) -> None: self.assertIn({'ext': 'png'}, fileextensions) # restrictions self.assertIn('restrictions', self.site.siteinfo) - restrictions = self.site.siteinfo.get('restrictions') + restrictions = self.site.restrictions self.assertIsInstance(restrictions, dict) self.assertIn('cascadinglevels', restrictions) From 7966845923c6d01f0d8539baf2312304c6386b44 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 13 Sep 2025 15:11:26 +0200 Subject: [PATCH 196/279] IMPR: Retrieve siteinfo with formatversion 2 - retrieve siteinfo with formatversion 2 - add '*' keys to 'namespaces', 'languages', 'namespacealiases' and 'skins' for backward compatibility - 'thumblimits', 'imagelimits' and 'magiclinks' entries of the 'general' property are normalized to lists for easier use and to match the format used in formatversion 1 - update Requests._handle_warnings to catch formatversion 2 warnings - update Requests.submit to handle formatversion 2 errors - update siteinfo usage - update DummySiteinfo and DrySite - update backports - import Dict and List from backports - cast types in Siteinfo._post_process function - update documentation Bug: T404301 Change-Id: I29a515d60330c6889a2cb6ceec4df58a417e5c72 --- pywikibot/backports.py | 2 + pywikibot/data/api/_requests.py | 18 ++++- pywikibot/site/_apisite.py | 18 ++--- pywikibot/site/_datasite.py | 19 ++--- pywikibot/site/_namespace.py | 8 +- pywikibot/site/_siteinfo.py | 138 ++++++++++++++++++-------------- tests/utils.py | 17 ++-- 7 files changed, 124 insertions(+), 96 deletions(-) diff --git a/pywikibot/backports.py b/pywikibot/backports.py index a3b64f773d..25b174ba3b 100644 --- a/pywikibot/backports.py +++ b/pywikibot/backports.py @@ -53,6 +53,7 @@ Generator, Iterable, Iterator, + List, Mapping, Match, Pattern, @@ -70,6 +71,7 @@ ) from re import Match, Pattern Dict = dict # type: ignore[misc] + List = list # type: ignore[misc] if PYTHON_VERSION < (3, 9, 2): diff --git a/pywikibot/data/api/_requests.py b/pywikibot/data/api/_requests.py index a49bc53dc0..035f65c28c 100644 --- a/pywikibot/data/api/_requests.py +++ b/pywikibot/data/api/_requests.py @@ -843,6 +843,10 @@ def _handle_warnings(self, result: dict[str, Any]) -> bool: .. versionchanged:: 7.2 Return True to retry the current request and False to resume. + .. versionchanged:: 10.5 + Handle warnings of formatversion 2. + + .. seealso:: :api:`Errors and warnings` :meta public: """ @@ -853,7 +857,9 @@ def _handle_warnings(self, result: dict[str, Any]) -> bool: for mod, warning in result['warnings'].items(): if mod == 'info': continue - if '*' in warning: + if 'warnings' in warning: # formatversion 2 + text = warning['warnings'] + elif '*' in warning: # formatversion 1 text = warning['*'] elif 'html' in warning: # bug T51978 @@ -1066,9 +1072,13 @@ def submit(self) -> dict: assert key not in error error[key] = result[key] - if '*' in error: - # help text returned - error['help'] = error.pop('*') + # help text returned + # see also: https://www.mediawiki.org/wiki/API:Errors_and_warnings + if 'docref' in error: + error['help'] = error.pop('docref') # formatversion 2 + elif '*' in error: + error['help'] = error.pop('*') # formatversion 1 + code = error.setdefault('code', 'Unknown') info = error.setdefault('info', None) diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py index 627d4d22f2..ec61c45de6 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -841,7 +841,7 @@ def articlepath(self) -> str: :raises ValueError: missing "$1" placeholder """ - path = self.siteinfo['general']['articlepath'] + path = self.siteinfo['articlepath'] if '$1' not in path: raise ValueError( f'Invalid article path "{path}": missing "$1" placeholder') @@ -864,7 +864,7 @@ def linktrail(self) -> str: 'ca': "(?:[a-zàèéíòóúç·ïü]|'(?!'))*", 'kaa': "(?:[a-zıʼ’“»]|'(?!'))*", } - linktrail = self.siteinfo['general']['linktrail'] + linktrail = self.siteinfo['linktrail'] if linktrail == '/^()(.*)$/sD': # empty linktrail return '' @@ -1183,10 +1183,10 @@ def _build_namespaces(self) -> dict[int, Namespace]: for nsdata in self.siteinfo.get('namespaces', cache=False).values(): ns = nsdata.pop('id') if ns == 0: - canonical_name = nsdata.pop('*') + custom_name = canonical_name = nsdata.pop('name') custom_name = canonical_name else: - custom_name = nsdata.pop('*') + custom_name = nsdata.pop('name') canonical_name = nsdata.pop('canonical') default_case = Namespace.default_case(ns) @@ -1199,16 +1199,16 @@ def _build_namespaces(self) -> dict[int, Namespace]: namespace = Namespace(ns, canonical_name, custom_name, **nsdata) _namespaces[ns] = namespace - for item in self.siteinfo.get('namespacealiases'): + for item in self.siteinfo['namespacealiases']: ns = int(item['id']) try: namespace = _namespaces[ns] except KeyError: pywikibot.warning('Broken namespace alias "{}" (id: {}) on {}' - .format(item['*'], ns, self)) + .format(item['alias'], ns, self)) else: - if item['*'] not in namespace: - namespace.aliases.append(item['*']) + if item['alias'] not in namespace: + namespace.aliases.append(item['alias']) return _namespaces @@ -3122,7 +3122,7 @@ def is_uploaddisabled(self) -> bool: >>> site.is_uploaddisabled() True """ - return not self.siteinfo.get('general')['uploadsenabled'] + return not self.siteinfo['uploadsenabled'] def stash_info( self, diff --git a/pywikibot/site/_datasite.py b/pywikibot/site/_datasite.py index cf09ee3018..9b3492eb1e 100644 --- a/pywikibot/site/_datasite.py +++ b/pywikibot/site/_datasite.py @@ -138,35 +138,32 @@ def get_entity_for_entity_id(self, entity_id): raise NoWikibaseEntityError(entity) @property - def sparql_endpoint(self): + def sparql_endpoint(self) -> str | None: """Return the sparql endpoint url, if any has been set. :return: sparql endpoint url - :rtype: str|None """ - return self.siteinfo['general'].get('wikibase-sparql') + return self.siteinfo.get('wikibase-sparql') @property - def concept_base_uri(self): + def concept_base_uri(self) -> str: """Return the base uri for concepts/entities. :return: concept base uri - :rtype: str """ - return self.siteinfo['general']['wikibase-conceptbaseuri'] + return self.siteinfo['wikibase-conceptbaseuri'] - def geo_shape_repository(self): + def geo_shape_repository(self) -> DataSite | None: """Return Site object for the geo-shapes repository e.g. commons.""" - url = self.siteinfo['general'].get('wikibase-geoshapestoragebaseurl') + url = self.siteinfo.get('wikibase-geoshapestoragebaseurl') if url: return pywikibot.Site(url=url, user=self.username()) return None - def tabular_data_repository(self): + def tabular_data_repository(self) -> DataSite | None: """Return Site object for the tabular-data repository e.g. commons.""" - url = self.siteinfo['general'].get( - 'wikibase-tabulardatastoragebaseurl') + url = self.siteinfo.get('wikibase-tabulardatastoragebaseurl') if url: return pywikibot.Site(url=url, user=self.username()) diff --git a/pywikibot/site/_namespace.py b/pywikibot/site/_namespace.py index 00de8fde7d..f82c03bcd9 100644 --- a/pywikibot/site/_namespace.py +++ b/pywikibot/site/_namespace.py @@ -1,6 +1,6 @@ """Objects representing Namespaces of MediaWiki site.""" # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -323,11 +323,7 @@ def normalize_name(name): class NamespacesDict(Mapping): - """An immutable dictionary containing the Namespace instances. - - It adds a deprecation message when called as the 'namespaces' - property of APISite was callable. - """ + """An immutable dictionary containing the Namespace instances.""" def __init__(self, namespaces) -> None: """Create new dict using the given namespaces.""" diff --git a/pywikibot/site/_siteinfo.py b/pywikibot/site/_siteinfo.py index 389947a0a5..46c6b9b26c 100644 --- a/pywikibot/site/_siteinfo.py +++ b/pywikibot/site/_siteinfo.py @@ -11,9 +11,10 @@ import re from collections.abc import Container from contextlib import suppress -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast import pywikibot +from pywikibot.backports import Dict, List from pywikibot.exceptions import APIError from pywikibot.tools.collections import EMPTY_DEFAULT @@ -24,43 +25,43 @@ class Siteinfo(Container): - """A 'dictionary' like container for siteinfo. + """A dictionary-like container for siteinfo. This class queries the server to get the requested siteinfo - property. Optionally it can cache this directly in the instance so - that later requests don't need to query the server. + property. Results can be cached in the instance to avoid repeated + queries. - All values of the siteinfo property 'general' are directly - available. + All values of the 'general' property are directly available. + + .. versionchanged:: 10.5 + formatversion 2 is used for API calls. + + .. admonition:: Compatibility note + :class: note + + For formatversion 2, some siteinfo data structures differ from + version 1. Fallback '*' keys are added in the data structure for + 'namespaces', 'languages', 'namespacealiases' and 'skins' + properties for backwards compatibility. These fallbacks may be + removed in future versions of Pywikibot. + + The 'thumblimits', 'imagelimits' and 'magiclinks' entries of the + 'general' property are normalized to lists for easier use and to + match the format used in formatversion 1. For example: + + :code:`'thumblimits': [120, 150, 180, 200, 220, 250, 300, 400]` + + .. deprecated:: 10.5 + Accessing the fallback '*' keys in 'languages', 'namespaces', + 'namespacealiases', and 'skins' properties are deprecated and + will be removed in a future release of Pywikibot. + + .. seealso:: :api:`siteinfo` """ WARNING_REGEX = re.compile(r'Unrecognized values? for parameter ' r'["\']siprop["\']: (.+?)\.?') - # Until we get formatversion=2, we have to convert empty-string properties - # into booleans so they are easier to use. - BOOLEAN_PROPS = { - 'general': [ - 'imagewhitelistenabled', - 'langconversion', - 'titleconversion', - 'rtl', - 'readonly', - 'writeapi', - 'variantarticlepath', - 'misermode', - 'uploadsenabled', - ], - 'namespaces': [ # for each namespace - 'subpages', - 'content', - 'nonincludable', - ], - 'magicwords': [ # for each magicword - 'case-sensitive', - ], - } - def __init__(self, site: APISite) -> None: """Initialize Siteinfo for a given site with an empty cache.""" self._site = site @@ -81,35 +82,40 @@ def _post_process(prop: str, Modifies *data* in place. + .. versionchanged:: 10.5 + Modify *data* for formatversion 1 compatibility and easier + to use lists. + :param prop: The siteinfo property name (e.g., 'general', 'namespaces', 'magicwords') :param data: The raw data returned from the server + + :meta public: """ # Be careful with version tests inside this here as it might need to # query this method to actually get the version number - # Convert boolean props from empty strings to actual boolean values - if prop not in Siteinfo.BOOLEAN_PROPS: - return - - bool_props = Siteinfo.BOOLEAN_PROPS[prop] if prop == 'general': - # Direct properties of 'general' - for p in bool_props: - data[p] = p in data - else: - # 'namespaces' (dict) or 'magicwords' (list of dicts) - items: list[dict[str, Any]] - if isinstance(data, dict): - items = list(data.values()) - elif isinstance(data, list): - items = data - else: - return # unexpected format - - for item in items: - for p in bool_props: - item[p] = p in item + data = cast(Dict[str, Any], data) + for key in 'thumblimits', 'imagelimits': + data[key] = list(data[key].values()) + data['magiclinks'] = [k for k, v in data['magiclinks'].items() + if v] + elif prop == 'namespaces': + data = cast(Dict[str, Any], data) + for ns_info in data.values(): + ns_info['*'] = ns_info['name'] + elif prop in ('languages', 'namespacealiases'): + data = cast(List[Dict[str, Any]], data) + for ns_info in data: + key = 'name' if 'name' in ns_info else 'alias' + ns_info['*'] = ns_info[key] + elif prop == 'skins': + data = cast(List[Dict[str, Any]], data) + for ns_info in data: + ns_info['*'] = ns_info['name'] + for key in 'default', 'unusable': + ns_info.setdefault(key, False) def _get_siteinfo(self, prop, expiry) -> dict: """Retrieve one or more siteinfo properties from the server. @@ -144,7 +150,10 @@ def warn_handler(mod, message) -> bool: expiry=pywikibot.config.API_config_expiry if expiry is False else expiry, parameters={ - 'action': 'query', 'meta': 'siteinfo', 'siprop': props, + 'action': 'query', + 'meta': 'siteinfo', + 'siprop': props, + 'formatversion': 2, } ) @@ -168,7 +177,7 @@ def warn_handler(mod, message) -> bool: return results raise - result = {} + result: dict[str, tuple[Any, datetime.datetime | Literal[False]]] = {} if invalid_properties: for invalid_prop in invalid_properties: result[invalid_prop] = (EMPTY_DEFAULT, False) @@ -334,18 +343,27 @@ def is_cached(self, key: str) -> bool: return True - def __contains__(self, key: str) -> bool: - """Return whether the value is in Siteinfo container. + def __contains__(self, key: object) -> bool: + """Check whether the given key is present in the Siteinfo container. + + This method implements the Container protocol and allows usage + like `key in container`.Only string keys are valid. Non-string + keys always return False. .. versionchanged:: 7.1 Previous implementation only checked for cached keys. + + :param key: The key to check for presence. Should be a string. + :return: True if the key exists in the container, False otherwise. + + :meta public: """ - try: - self[key] - except KeyError: - return False + if isinstance(key, str): + with suppress(KeyError): + self[key] + return True - return True + return False def is_recognised(self, key: str) -> bool | None: """Return if 'key' is a valid property name. diff --git a/tests/utils.py b/tests/utils.py index 44d8227607..cf5860fa5b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -261,16 +261,21 @@ def __setitem__(self, key, value) -> None: self._cache[key] = (value, False) def get(self, key, get_default=True, cache=True, expiry=False): - """Return dry data.""" + """Return dry cached data or default value.""" # Default values are always expired, so only expiry=False doesn't force # a reload force = expiry is not False - if not force and key in self._cache: - loaded = self._cache[key] - if not loaded[1] and not get_default: + if not force and (key in self._cache or 'general' in self._cache): + try: + value, is_default = self._cache[key] + except KeyError: + value, is_default = self._cache['general'] + value = value[key] + + if not is_default and not get_default: raise KeyError(key) - return loaded[0] + return value if get_default: default = EMPTY_DEFAULT @@ -343,7 +348,7 @@ def __init__(self, code, fam, user) -> None: self._siteinfo._cache['case'] = ( 'case-sensitive' if self.family.name == 'wiktionary' else 'first-letter', True) - self._siteinfo._cache['mainpage'] = 'Main Page' + self._siteinfo._cache['mainpage'] = ('Main Page', True) extensions = [] if self.family.name == 'wikisource': extensions.append({'name': 'ProofreadPage'}) From e14a5df68d094663699e2469ddad3952e280ed7f Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 20 Sep 2025 16:27:55 +0200 Subject: [PATCH 197/279] Tests: ignore en.citizendium.org test in site_detect_tests on github Bug: T404583 Change-Id: I6b4bda37df72cd52a3342e1e6b0c2d1d1457c450 --- tests/site_detect_tests.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/site_detect_tests.py b/tests/site_detect_tests.py index 4d81e8fd4b..28de946be0 100755 --- a/tests/site_detect_tests.py +++ b/tests/site_detect_tests.py @@ -103,7 +103,11 @@ class MediaWikiSiteTestCase(SiteDetectionTestCase): def test_standard_version_sites(self) -> None: """Test detection of standard MediaWiki sites.""" for url in self.standard_version_sites: - with self.subTest(url=urlparse(url).netloc): + nl = urlparse(url).netloc + with self.subTest(url=nl): + if os.getenv('GITHUB_ACTIONS') and nl == 'en.citizendium.org': + self.skipTest('Skip test on github due to T404583') + self.assertSite(url) def test_proofreadwiki(self) -> None: From 7e594d399661f294ade2b645f8472d8f346825d2 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 20 Sep 2025 17:42:16 +0200 Subject: [PATCH 198/279] Tests: Use site.user() in TestRollbackPage Error tests Bug: T405145 Change-Id: Iaab46f436e64e1069d0a3871ae6de64babb281e8 --- tests/site_tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/site_tests.py b/tests/site_tests.py index ba06e324f2..87a512a609 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -828,7 +828,8 @@ def test_missing_rights(self): 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' + rf'User "{self.site.user()}" does not have required user right' + ' "rollback" on site' ): self.site.rollbackpage(self.page, pageid=4711) From 42aba88c43c5d94e835b516bc354b51b4945b817 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 21 Sep 2025 08:17:52 +0200 Subject: [PATCH 199/279] [10.5.0] Publisch Pywikibot 10.5 Change-Id: I85e9828cb133ea94a0f2169e6ee727d06c0aeb0b --- ROADMAP.rst | 21 +++++++++++++++++++++ pywikibot/__metadata__.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index 721ed5a1cb..e96964fba2 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,6 +1,20 @@ Current Release Changes ======================= +* :class:`Siteinfo` query is made with formatversion 2. Several + boolean values are available now. Note that '*' keys for some data are kept for backward + compatibility but may be removed later. (:phab:`T404301`) +* A new property :attr:`APISite.restrictions` was + added. It replaces the methods :meth:`APISite.protection_types() + ` and :meth:`APISite.protection_levels() + ` which are deprecated now. +* Support for mswikiquote was added (:phab:`T404702`) +* :meth:`APISite.rollbackpage()` supports *pageid* + argument as alternative to *page*. *markbot* defaults to True if the rollbacker is a bot and not + explicitly given. The method now returns a dictionary with rollback information. The version + history no longer has to be preloaded. (:phab:`T403425`) +* :meth:`BasePage.rollback()` was implemented (:phab:`T403425`) +* The first parameter of :exc:`exceptions.PageRelatedError` may now be a pageid (:phab:`T403425`) * i18n Updates * Use 'login' token from API response in :meth:`login.ClientLoginManager.login_to_site` (:phab:`T328814`) @@ -14,6 +28,13 @@ Deprecations Pending removal in Pywikibot 13 ------------------------------- +* 10.5.0: Accessing the fallback '*' keys in 'languages', 'namespaces', 'namespacealiases', and + 'skins' properties of :attr:`APISite.siteinfo` are + deprecated and will be removed. +* 10.5.0: The methods :meth:`APISite.protection_types() + ` and :meth:`APISite.protection_levels() + ` are deprecated. + :attr:`APISite.restrictions` should be used instead. * 10.4.0: Require all parameters of :meth:`Site.allpages() ` except *start* to be keyword arguments. * 10.4.0: Positional arguments of :class:`pywikibot.Coordinate` are deprecated and must be given as diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index f27890405a..7daac19191 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.5.0.dev0' +__version__ = '10.5.0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' From 374b1e34b9472d60bb979e70d8c8eb5cf858dfeb Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 21 Sep 2025 10:22:59 +0200 Subject: [PATCH 200/279] [10.6] Prepare next release 10.6 Change-Id: Ie6befe02381732f297d29810fc093b43b25f1021 --- .pre-commit-config.yaml | 4 ++-- HISTORY.rst | 25 +++++++++++++++++++++++++ ROADMAP.rst | 20 +------------------- dev-requirements.txt | 8 ++++---- docs/conf.py | 2 +- docs/requirements.txt | 8 ++++---- pywikibot/__metadata__.py | 2 +- scripts/__init__.py | 2 +- scripts/pyproject.toml | 2 +- 9 files changed, 40 insertions(+), 33 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30067cc640..4544265701 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.0 + rev: v0.13.1 hooks: - id: ruff-check alias: ruff @@ -113,7 +113,7 @@ repos: - flake8-tuple>=0.4.1 - pep8-naming>=0.15.1 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.1 + rev: v1.18.2 hooks: - id: mypy args: diff --git a/HISTORY.rst b/HISTORY.rst index 46ea47171e..ed084d555d 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,31 @@ Release History =============== +10.5.0 +------ +*21 September 2025* + +* :class:`Siteinfo` query is made with formatversion 2. Several + boolean values are available now. Note that '*' keys for some data are kept for backward + compatibility but may be removed later. (:phab:`T404301`) +* A new property :attr:`APISite.restrictions` was + added. It replaces the methods :meth:`APISite.protection_types() + ` and :meth:`APISite.protection_levels() + ` which are deprecated now. +* Support for mswikiquote was added (:phab:`T404702`) +* :meth:`APISite.rollbackpage()` supports *pageid* + argument as alternative to *page*. *markbot* defaults to True if the rollbacker is a bot and not + explicitly given. The method now returns a dictionary with rollback information. The version + history no longer has to be preloaded. (:phab:`T403425`) +* :meth:`BasePage.rollback()` was implemented (:phab:`T403425`) +* The first parameter of :exc:`exceptions.PageRelatedError` may now be a pageid (:phab:`T403425`) +* i18n Updates +* Use 'login' token from API response in :meth:`login.ClientLoginManager.login_to_site` + (:phab:`T328814`) +* Always use *fallback_prompt* in :func:`i18n.twtranslate` whenever no + translation is found, including unknown keys in existing packages (:phab:`T326470`) + + 10.4.0 ------ *31 August 2025* diff --git a/ROADMAP.rst b/ROADMAP.rst index e96964fba2..ba5f818ffc 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,25 +1,7 @@ Current Release Changes ======================= -* :class:`Siteinfo` query is made with formatversion 2. Several - boolean values are available now. Note that '*' keys for some data are kept for backward - compatibility but may be removed later. (:phab:`T404301`) -* A new property :attr:`APISite.restrictions` was - added. It replaces the methods :meth:`APISite.protection_types() - ` and :meth:`APISite.protection_levels() - ` which are deprecated now. -* Support for mswikiquote was added (:phab:`T404702`) -* :meth:`APISite.rollbackpage()` supports *pageid* - argument as alternative to *page*. *markbot* defaults to True if the rollbacker is a bot and not - explicitly given. The method now returns a dictionary with rollback information. The version - history no longer has to be preloaded. (:phab:`T403425`) -* :meth:`BasePage.rollback()` was implemented (:phab:`T403425`) -* The first parameter of :exc:`exceptions.PageRelatedError` may now be a pageid (:phab:`T403425`) -* i18n Updates -* Use 'login' token from API response in :meth:`login.ClientLoginManager.login_to_site` - (:phab:`T328814`) -* Always use *fallback_prompt* in :func:`i18n.twtranslate` whenever no - translation is found, including unknown keys in existing packages (:phab:`T326470`) +* (no changes yet) Deprecations diff --git a/dev-requirements.txt b/dev-requirements.txt index c5465e1f12..4f1bef5cb5 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,16 +1,16 @@ # This is a PIP 6+ requirements file for development dependencies # -pytest >= 8.3.4 -pytest-subtests >= 0.14.1; python_version > "3.8" +pytest >= 8.3.5 +pytest-subtests >= 0.14.2; python_version > "3.8" pytest-subtests == 0.13.1; python_version < "3.9" pytest-attrib>=0.1.3 pytest-xvfb>=3.0.0 -pre-commit >= 4.2.0; python_version > "3.8" +pre-commit >= 4.3.0; python_version > "3.8" pre-commit == 3.5.0; python_version < "3.9" coverage==7.6.1; python_version < "3.9" -coverage>=7.6.12; python_version > "3.8" +coverage>=7.10.6; python_version > "3.8" # required for coverage (T380697) tomli>=2.2.1; python_version < "3.11" diff --git a/docs/conf.py b/docs/conf.py index e3b086d1ff..63801d7d6a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,7 +46,7 @@ # If your documentation needs a minimal Sphinx version, state it here. # -needs_sphinx = '8.2.1' +needs_sphinx = '8.2.3' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom diff --git a/docs/requirements.txt b/docs/requirements.txt index 3f42299963..ad2bb0f4ae 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,9 +1,9 @@ # This is a PIP requirements file for building Sphinx documentation of Pywikibot # ../requirements.txt is also needed. # Note: Python 3.11 is required for sphinx 8.2 -sphinx >= 8.2.1 -rstcheck >=6.2.4 -sphinxext-opengraph >= 0.9.1 +sphinx >= 8.2.3 +rstcheck >=6.2.5 +sphinxext-opengraph >= 0.13.0 sphinx-copybutton >= 0.5.2 sphinx-tabs >= 3.4.7 -furo >= 2024.8.6 +furo >= 2025.7.19 diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index 7daac19191..2004918e7b 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.5.0' +__version__ = '10.6.0.dev0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' diff --git a/scripts/__init__.py b/scripts/__init__.py index f941522e5f..4037f0b221 100644 --- a/scripts/__init__.py +++ b/scripts/__init__.py @@ -34,7 +34,7 @@ from pathlib import Path -__version__ = '10.5.0' +__version__ = '10.6.0' #: defines the entry point for pywikibot-scripts package base_dir = Path(__file__).parent diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index 72811b76dc..4b4e4037b2 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -7,7 +7,7 @@ package-dir = {"pywikibot_scripts" = "scripts"} [project] name = "pywikibot-scripts" -version = "10.5.0" +version = "10.6.0" authors = [ {name = "xqt", email = "info@gno.de"}, From 89d2e4f05b73e9d7f791af3cedf62ff7eb711fd6 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 21 Sep 2025 10:54:46 +0200 Subject: [PATCH 201/279] MyPy: Solve some MyPy issues in several files Also fix pre-commit mypy check Change-Id: I1f9000f9b33df83dda68b6c7734dfc0d5805442c --- .pre-commit-config.yaml | 28 +++++++++++++++++----------- conftest.py | 9 +++++---- pywikibot/backports.py | 22 +++++++++++++++------- pywikibot/comms/eventstreams.py | 4 ++-- pywikibot/data/api/__init__.py | 9 +++++---- pywikibot/data/api/_optionset.py | 25 +++++++++++++++---------- pywikibot/data/api/_paraminfo.py | 7 +++++-- pywikibot/data/api/_requests.py | 2 +- pywikibot/data/sparql.py | 7 ++++--- pywikibot/diff.py | 19 +++++++++++-------- pywikibot/page/_category.py | 4 ++-- pywikibot/page/_collections.py | 10 +++++----- pywikibot/page/_user.py | 9 +++++---- pywikibot/pagegenerators/_factory.py | 4 ++-- pywikibot/pagegenerators/_filters.py | 16 +++++++++------- pywikibot/site/_extensions.py | 13 +++++++++---- tox.ini | 1 + 17 files changed, 113 insertions(+), 76 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4544265701..cc0cae2e71 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -119,16 +119,22 @@ repos: args: - --config-file=pyproject.toml - --follow-imports=silent + additional_dependencies: + - types-PyMySQL + - types-requests # Test for files which already passed in past. # They should be also used in conftest.py to exclude them from non-voting mypy test. - files: > - ^pywikibot/(__metadata__|config|echo|exceptions|fixes|logging|time)\.py$| - ^pywikibot/(comms|data|families|specialbots)/__init__\.py$| - ^pywikibot/data/memento\.py$| - ^pywikibot/families/[a-z][a-z\d]+_family\.py$| - ^pywikibot/page/(__init__|_decorators|_revision)\.py$| - ^pywikibot/pagegenerators/__init__\.py$| - ^pywikibot/scripts/(?:i18n/)?__init__\.py$| - ^pywikibot/site/(__init__|_basesite|_decorators|_extensions|_interwikimap|_tokenwallet|_upload)\.py$| - ^pywikibot/tools/(_logging|_unidata|formatter)\.py$| - ^pywikibot/userinterfaces/(__init__|_interface_base|terminal_interface)\.py$ + files: | + (?x)^pywikibot/( + (__metadata__|backports|config|diff|echo|exceptions|fixes|logging|time)| + (comms|data|families|specialbots)/__init__| + comms/eventstreams| + data/(api/(__init__|_optionset)|memento)| + families/[a-z][a-z\d]+_family| + page/(__init__|_decorators|_revision)| + pagegenerators/(__init__|_filters)| + scripts/(?:i18n/)?__init__| + site/(__init__|_basesite|_decorators|_interwikimap|_tokenwallet|_upload)| + tools/(_logging|_unidata|formatter)| + userinterfaces/(__init__|_interface_base|terminal_interface) + )\.py$ diff --git a/conftest.py b/conftest.py index c009871f47..5e92cd4c75 100644 --- a/conftest.py +++ b/conftest.py @@ -16,14 +16,15 @@ EXCLUDE_PATTERN = re.compile( r'(?:' - r'(__metadata__|config|echo|exceptions|fixes|logging|time)|' + r'(__metadata__|backports|config|diff|echo|exceptions|fixes|logging|time)|' r'(comms|data|families|specialbots)/__init__|' - r'data/memento|' + r'comms/eventstreams|' + r'data/(api/(__init__|_optionset)|memento)|' r'families/[a-z][a-z\d]+_family|' r'page/(__init__|_decorators|_revision)|' - r'pagegenerators/__init__|' + r'pagegenerators/(__init__|_filters)|' r'scripts/(i18n/)?__init__|' - r'site/(__init__|_basesite|_decorators|_extensions|_interwikimap|' + r'site/(__init__|_basesite|_decorators|_interwikimap|' r'_tokenwallet|_upload)|' r'tools/(_logging|_unidata|formatter)|' r'userinterfaces/(__init__|_interface_base|terminal_interface)' diff --git a/pywikibot/backports.py b/pywikibot/backports.py index 25b174ba3b..00636aa45f 100644 --- a/pywikibot/backports.py +++ b/pywikibot/backports.py @@ -16,7 +16,7 @@ import re import sys -from typing import Any +from typing import TYPE_CHECKING, Any # Placed here to omit circular import in tools @@ -58,6 +58,7 @@ Match, Pattern, Sequence, + Set, ) else: from collections import Counter @@ -72,6 +73,7 @@ from re import Match, Pattern Dict = dict # type: ignore[misc] List = list # type: ignore[misc] + Set = set # type: ignore[misc] if PYTHON_VERSION < (3, 9, 2): @@ -137,8 +139,9 @@ def pairwise(iterable): a, b = tee(iterable) next(b, None) return zip(a, b) -else: - from itertools import pairwise # type: ignore[no-redef] + +elif not TYPE_CHECKING: + from itertools import pairwise from types import NoneType @@ -202,13 +205,18 @@ def batched(iterable, n: int, *, raise ValueError(msg) yield tuple(group) else: # PYTHON_VERSION == (3, 12) - from itertools import batched as _batched + if TYPE_CHECKING: + _batched: Callable[[Iterable, int], Iterable] + else: + from itertools import batched as _batched + for group in _batched(iterable, n): if strict and len(group) < n: raise ValueError(msg) yield group -else: - from itertools import batched # type: ignore[no-redef] + +elif not TYPE_CHECKING: + from itertools import batched # gh-115942, gh-134323 @@ -291,4 +299,4 @@ def locked(self): return status == 'locked' else: - from threading import RLock + from threading import RLock # type: ignore[assignment] diff --git a/pywikibot/comms/eventstreams.py b/pywikibot/comms/eventstreams.py index 81a32fe25c..6db46d938b 100644 --- a/pywikibot/comms/eventstreams.py +++ b/pywikibot/comms/eventstreams.py @@ -26,7 +26,7 @@ from requests.packages.urllib3.util.response import httplib from pywikibot import Site, Timestamp, config, debug, warning -from pywikibot.backports import NoneType +from pywikibot.backports import Dict, List, NoneType from pywikibot.comms.http import user_agent from pywikibot.tools import cached, deprecated_args from pywikibot.tools.collections import GeneratorWrapper @@ -179,7 +179,7 @@ def __init__(self, **kwargs) -> None: if isinstance(EventSource, ModuleNotFoundError): raise ImportError(INSTALL_MSG) from EventSource - self.filter = {'all': [], 'any': [], 'none': []} + self.filter: Dict[str, List[Any]] = {'all': [], 'any': [], 'none': []} self._total: int | None = None self._canary = kwargs.pop('canary', False) diff --git a/pywikibot/data/api/__init__.py b/pywikibot/data/api/__init__.py index 0af2e3ede1..c5af36cb00 100644 --- a/pywikibot/data/api/__init__.py +++ b/pywikibot/data/api/__init__.py @@ -1,6 +1,6 @@ """Interface to MediaWiki's api.php.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -55,7 +55,7 @@ def _invalidate_superior_cookies(family) -> None: """ if isinstance(family, SubdomainFamily): for cookie in http.cookie_jar: - if family.domain == cookie.domain: + if family.domain == cookie.domain: # type: ignore[attr-defined] http.cookie_jar.clear(cookie.domain, cookie.path, cookie.name) @@ -71,9 +71,10 @@ class CTEBinaryBytesGenerator(BytesGenerator): def _handle_text(self, msg) -> None: if msg['content-transfer-encoding'] == 'binary': - self._fp.write(msg.get_payload(decode=True)) + self._fp.write( # type: ignore[attr-defined] + msg.get_payload(decode=True)) else: - super()._handle_text(msg) + super()._handle_text(msg) # type: ignore[misc] _writeBody = _handle_text # noqa: N815 diff --git a/pywikibot/data/api/_optionset.py b/pywikibot/data/api/_optionset.py index 4ba56742df..c494ed4444 100644 --- a/pywikibot/data/api/_optionset.py +++ b/pywikibot/data/api/_optionset.py @@ -9,6 +9,7 @@ from collections.abc import MutableMapping import pywikibot +from pywikibot.backports import Set from pywikibot.tools import deprecate_arg @@ -41,21 +42,26 @@ def __init__(self, *dict* parameter was renamed to *data*. :param site: The associated site - :param module: The module name which is used by paraminfo. (Ignored - when site is None) - :param param: The parameter name inside the module. That parameter must - have a 'type' entry. (Ignored when site is None) + :param module: The module name which is used by paraminfo. + (Ignored when site is None) + :param param: The parameter name inside the module. That + parameter must have a 'type' entry. (Ignored when site is + None) :param data: The initializing data dict which is used for :meth:`from_dict` """ self._site_set = False - self._enabled = set() - self._disabled = set() + self._enabled: Set[str] = set() + self._disabled: Set[str] = set() self._set_site(site, module, param) if data: self.from_dict(data) - def _set_site(self, site, module: str, param: str, *, + def _set_site(self, + site: pywikibot.site.APISite | None, + module: str | None, + param: str | None, + *, clear_invalid: bool = False) -> None: """Set the site and valid names. @@ -64,7 +70,6 @@ def _set_site(self, site, module: str, param: str, *, thrown. :param site: The associated site - :type site: pywikibot.site.APISite :param module: The module name which is used by paraminfo. :param param: The parameter name inside the module. That parameter must have a 'type' entry. @@ -80,6 +85,7 @@ def _set_site(self, site, module: str, param: str, *, self._valid_disable = set() if site is None: return + for type_value in site._paraminfo.parameter(module, param)['type']: if type_value[0] == '!': self._valid_disable.add(type_value[1:]) @@ -96,7 +102,7 @@ def _set_site(self, site, module: str, param: str, *, '"{}"'.format('", "'.join(invalid_names))) self._site_set = True - def from_dict(self, dictionary) -> None: + def from_dict(self, dictionary: dict[str, bool | None]) -> None: """Load options from the dict. The options are not cleared before. If changes have been made @@ -107,7 +113,6 @@ def from_dict(self, dictionary) -> None: the value False, True or None. The names must be valid depending on whether they enable or disable the option. All names with the value None can be in either of the list. - :type dictionary: dict (keys are strings, values are bool/None) """ enabled = set() disabled = set() diff --git a/pywikibot/data/api/_paraminfo.py b/pywikibot/data/api/_paraminfo.py index 44fd7c4b69..beaf551842 100644 --- a/pywikibot/data/api/_paraminfo.py +++ b/pywikibot/data/api/_paraminfo.py @@ -11,7 +11,7 @@ import pywikibot from pywikibot import config -from pywikibot.backports import Iterable, batched +from pywikibot.backports import Dict, Iterable, Set, batched from pywikibot.tools import ( classproperty, deprecated, @@ -36,6 +36,9 @@ class ParamInfo(Sized, Container): init_modules = frozenset(['main', 'paraminfo']) param_modules = ('list', 'meta', 'prop') + _action_modules: frozenset[str] + _modules: Dict[str, Set[str] | Dict[str, str]] + @remove_last_args(['modules_only_mode']) def __init__(self, site, @@ -72,7 +75,7 @@ def _add_submodules(self, name: str, if self._action_modules: assert modules == self._action_modules else: - self._action_modules = modules + self._action_modules = frozenset(modules) elif name in self._modules: # update required to updates from dict and set self._modules[name].update(modules) diff --git a/pywikibot/data/api/_requests.py b/pywikibot/data/api/_requests.py index 035f65c28c..5fd6e11bf4 100644 --- a/pywikibot/data/api/_requests.py +++ b/pywikibot/data/api/_requests.py @@ -1080,7 +1080,7 @@ def submit(self) -> dict: error['help'] = error.pop('*') # formatversion 1 code = error.setdefault('code', 'Unknown') - info = error.setdefault('info', None) + info = error.setdefault('info', '') if (code == self.last_error['code'] and info == self.last_error['info']): diff --git a/pywikibot/data/sparql.py b/pywikibot/data/sparql.py index 4713888ca3..ca5042d819 100644 --- a/pywikibot/data/sparql.py +++ b/pywikibot/data/sparql.py @@ -1,19 +1,20 @@ """SPARQL Query interface.""" # -# (C) Pywikibot team, 2016-2024 +# (C) Pywikibot team, 2016-2025 # # Distributed under the terms of the MIT license. # from __future__ import annotations from textwrap import fill +from typing import Any from urllib.parse import quote from requests import JSONDecodeError from requests.exceptions import Timeout from pywikibot import Site -from pywikibot.backports import removeprefix +from pywikibot.backports import Dict, removeprefix from pywikibot.comms import http from pywikibot.data import WaitingMixin from pywikibot.exceptions import Error, NoUsernameError, ServerError @@ -111,7 +112,7 @@ def select(self, result = [] qvars = data['head']['vars'] for row in data['results']['bindings']: - values = {} + values: Dict[str, Any] = {} for var in qvars: if var not in row: # var is not available (OPTIONAL is probably used) diff --git a/pywikibot/diff.py b/pywikibot/diff.py index 052e93474c..c1ba3a87c3 100644 --- a/pywikibot/diff.py +++ b/pywikibot/diff.py @@ -1,6 +1,6 @@ """Diff module.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -9,7 +9,8 @@ import difflib import math from collections import abc -from difflib import SequenceMatcher, _format_range_unified +from difflib import _format_range_unified # type: ignore[attr-defined] +from difflib import SequenceMatcher from heapq import nlargest from itertools import zip_longest @@ -612,12 +613,14 @@ def html_comparator(compare_string: str) -> dict[str, list[str]]: return comparands -def get_close_matches_ratio(word: Sequence, - possibilities: list[Sequence], - *, - n: int = 3, - cutoff: float = 0.6, - ignorecase: bool = False) -> list[float, Sequence]: +def get_close_matches_ratio( + word: str, + possibilities: list[str], + *, + n: int = 3, + cutoff: float = 0.6, + ignorecase: bool = False +) -> list[tuple[float, str]]: """Return a list of the best “good enough” matches and its ratio. This method is similar to Python's :pylib:`difflib.get_close_matches() diff --git a/pywikibot/page/_category.py b/pywikibot/page/_category.py index f352394e01..ccc457eaaa 100644 --- a/pywikibot/page/_category.py +++ b/pywikibot/page/_category.py @@ -61,7 +61,7 @@ def aslink(self, sort_key: str | None = None) -> str: def subcategories(self, *, recurse: int | bool = False, - **kwargs: Any) -> Generator[Page, None, None]: + **kwargs: Any) -> Generator[Category, None, None]: """Yield all subcategories of the current category. **Usage:** @@ -200,7 +200,7 @@ def articles(self, *, return def members(self, *, - recurse: bool = False, + recurse: int | bool = False, total: int | None = None, **kwargs: Any) -> Generator[Page, None, None]: """Yield all category contents (subcats, pages, and files). diff --git a/pywikibot/page/_collections.py b/pywikibot/page/_collections.py index b3b79843bc..dea73af86c 100644 --- a/pywikibot/page/_collections.py +++ b/pywikibot/page/_collections.py @@ -32,7 +32,7 @@ class BaseDataDict(MutableMapping): in subclasses. """ - def __init__(self, data=None) -> None: + def __init__(self, data: dict[str, Any] = None) -> None: super().__init__() self._data = {} if data: @@ -43,15 +43,15 @@ def new_empty(cls, repo): """Construct a new empty BaseDataDict.""" return cls() - def __getitem__(self, key): + def __getitem__(self, key: BaseSite | str) -> Any: key = self.normalizeKey(key) return self._data[key] - def __setitem__(self, key, value) -> None: + def __setitem__(self, key: BaseSite | str, value: Any) -> None: key = self.normalizeKey(key) self._data[key] = value - def __delitem__(self, key) -> None: + def __delitem__(self, key: BaseSite | str) -> None: key = self.normalizeKey(key) del self._data[key] @@ -61,7 +61,7 @@ def __iter__(self): def __len__(self) -> int: return len(self._data) - def __contains__(self, key) -> bool: + def __contains__(self, key: BaseSite | str) -> bool: key = self.normalizeKey(key) return key in self._data diff --git a/pywikibot/page/_user.py b/pywikibot/page/_user.py index 206c1be7d3..8f515e6124 100644 --- a/pywikibot/page/_user.py +++ b/pywikibot/page/_user.py @@ -112,12 +112,13 @@ def getprops(self, force: bool = False) -> dict: if force and hasattr(self, '_userprops'): del self._userprops if not hasattr(self, '_userprops'): - self._userprops = list(self.site.users([self.username]))[0] + self._userprops = next(self.site.users([self.username])) if self.isAnonymous() or self.is_CIDR(): - r = list(self.site.blocks(iprange=self.username, total=1)) + r = next(self.site.blocks(iprange=self.username, total=1), + None) if r: - self._userprops['blockedby'] = r[0]['by'] - self._userprops['blockreason'] = r[0]['reason'] + self._userprops['blockedby'] = r['by'] + self._userprops['blockreason'] = r['reason'] return self._userprops def registration(self, diff --git a/pywikibot/pagegenerators/_factory.py b/pywikibot/pagegenerators/_factory.py index 249466d51c..ba4c648c6c 100644 --- a/pywikibot/pagegenerators/_factory.py +++ b/pywikibot/pagegenerators/_factory.py @@ -62,13 +62,13 @@ if TYPE_CHECKING: - from typing import Any, Literal + from typing import Any, Literal, Optional from pywikibot.site import BaseSite, Namespace HANDLER_GEN_TYPE = Iterable[pywikibot.page.BasePage] GEN_FACTORY_CLAIM_TYPE = list[tuple[str, str, dict[str, str], bool]] - OPT_GENERATOR_TYPE = HANDLER_GEN_TYPE | None + OPT_GENERATOR_TYPE = Optional[HANDLER_GEN_TYPE] # This is the function that will be used to de-duplicate page iterators. diff --git a/pywikibot/pagegenerators/_filters.py b/pywikibot/pagegenerators/_filters.py index cd006e2a14..641724d1ba 100644 --- a/pywikibot/pagegenerators/_filters.py +++ b/pywikibot/pagegenerators/_filters.py @@ -1,6 +1,6 @@ """Page filter generators provided by the pagegenerators module.""" # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # @@ -20,16 +20,18 @@ if TYPE_CHECKING: + from typing import Union + from pywikibot.site import BaseSite, Namespace PRELOAD_SITE_TYPE = dict[pywikibot.site.BaseSite, list[pywikibot.page.BasePage]] - PATTERN_STR_OR_SEQ_TYPE = ( - str - | Pattern[str] - | Sequence[str] - | Sequence[Pattern[str]] - ) + PATTERN_STR_OR_SEQ_TYPE = Union[ + str, + Pattern[str], + Sequence[str], + Sequence[Pattern[str]], + ] # This is the function that will be used to de-duplicate page iterators. diff --git a/pywikibot/site/_extensions.py b/pywikibot/site/_extensions.py index 7e8191d4d1..8b15cf846d 100644 --- a/pywikibot/site/_extensions.py +++ b/pywikibot/site/_extensions.py @@ -50,6 +50,11 @@ def namespaces(self, **kwargs) -> NamespacesDict: def simple_request(self, **kwargs) -> api.Request: ... + def querypage( + self, *args, **kwargs + ) -> Generator[tuple[pywikibot.Page, int], None, None]: + ... + class EchoMixin: @@ -287,7 +292,7 @@ class WikibaseClientMixin: @need_extension('WikibaseClient') def unconnected_pages( - self, + self: BaseSiteProtocol, total: int | None = None, *, strict: bool = False @@ -305,7 +310,7 @@ def unconnected_pages( :param strict: If ``True``, verify that each page still has no data item before yielding it. """ - if total <= 0: + if total is not None and total <= 0: return if not strict: @@ -329,9 +334,9 @@ class LinterMixin: @need_extension('Linter') def linter_pages( - self, + self: BaseSiteProtocol, lint_categories=None, - total: int = None, + total: int | None = None, namespaces=None, pageids: str | int | None = None, lint_from: str | int | None = None diff --git a/tox.ini b/tox.ini index a949b3db85..d47e26c012 100644 --- a/tox.ini +++ b/tox.ini @@ -66,6 +66,7 @@ deps = basepython = python3.9 deps = pytest-mypy + types-PyMySQL types-requests commands = mypy --version From ab42d283f791e7f695bd227650cf3b0e6ea917fe Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 21 Sep 2025 16:50:29 +0200 Subject: [PATCH 202/279] [bugfix] Return userPut result with put_current method in AutomaticTWSummaryBot Change-Id: If62349de27bb3f6845101822b8d62f8078f6ee94 --- ROADMAP.rst | 3 ++- pywikibot/bot.py | 43 ++++++++++++++++++++++++------------ tests/interwikidata_tests.py | 5 +++-- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index ba5f818ffc..69367fdcba 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,7 +1,8 @@ Current Release Changes ======================= -* (no changes yet) +* Return :meth:`bot.BaseBot.userPut` result with :meth:`AutomaticTWSummaryBot.put_current() + ` method Deprecations diff --git a/pywikibot/bot.py b/pywikibot/bot.py index 9aab5c25b4..d3457ae2a8 100644 --- a/pywikibot/bot.py +++ b/pywikibot/bot.py @@ -1840,21 +1840,26 @@ def treat(self, page: pywikibot.page.BasePage) -> None: self.current_page = page self.treat_page() - def put_current(self, new_text: str, - ignore_save_related_errors: bool | None = None, - ignore_server_errors: bool | None = None, - **kwargs: Any) -> bool: + def put_current( + self, + new_text: str, + ignore_save_related_errors: bool | None = None, + ignore_server_errors: bool | None = None, + **kwargs: Any + ) -> bool: """Call :py:obj:`Bot.userPut` but use the current page. It compares the new_text to the current page text. :param new_text: The new text - :param ignore_save_related_errors: Ignore save related errors and - automatically print a message. If None uses this instances default. - :param ignore_server_errors: Ignore server errors and automatically - print a message. If None uses this instances default. + :param ignore_save_related_errors: Ignore save related errors + and automatically print a message. If None uses this + instances default. + :param ignore_server_errors: Ignore server errors and + automatically print a message. If None uses this instances + default. :param kwargs: Additional parameters directly given to - :py:obj:`Bot.userPut`. + :meth:`BaseBot.userPut`. :return: whether the page was saved successfully """ if ignore_save_related_errors is None: @@ -1862,10 +1867,13 @@ def put_current(self, new_text: str, if ignore_server_errors is None: ignore_server_errors = self.ignore_server_errors return self.userPut( - self.current_page, self.current_page.text, new_text, + self.current_page, + self.current_page.text, + new_text, ignore_save_related_errors=ignore_save_related_errors, ignore_server_errors=ignore_server_errors, - **kwargs) + **kwargs + ) class AutomaticTWSummaryBot(CurrentPageBot): @@ -1902,8 +1910,14 @@ def summary_parameters(self) -> None: """Delete the i18n dictionary.""" del self._summary_parameters - def put_current(self, *args: Any, **kwargs: Any) -> None: - """Defining a summary if not already defined and then call original.""" + def put_current(self, *args: Any, **kwargs: Any) -> bool: + """Defining a summary if not already defined and then call original. + + For parameters see :meth:`CurrentPageBot.put_current` + + .. versionchanged:: 10.6 + return whether the page was saved successfully + """ if not kwargs.get('summary'): if self.summary_key is None: raise ValueError('The summary_key must be set.') @@ -1912,7 +1926,8 @@ def put_current(self, *args: Any, **kwargs: Any) -> None: self.summary_parameters) _log(f'Use automatic summary message "{summary}"') kwargs['summary'] = summary - super().put_current(*args, **kwargs) + + return super().put_current(*args, **kwargs) class ExistingPageBot(CurrentPageBot): diff --git a/tests/interwikidata_tests.py b/tests/interwikidata_tests.py index 7a77ef3b65..97291ef29a 100755 --- a/tests/interwikidata_tests.py +++ b/tests/interwikidata_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for scripts/interwikidata.py.""" # -# (C) Pywikibot team, 2015-2024 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -9,6 +9,7 @@ import unittest from contextlib import suppress +from typing import Any import pywikibot from pywikibot import Link @@ -21,7 +22,7 @@ class DummyBot(interwikidata.IWBot): """A dummy bot to prevent editing in production wikis.""" - def put_current(self) -> bool: + def put_current(self, *args: Any, **kwargs: Any) -> bool: """Prevent editing.""" return False From 9b4895a3ec792251f54610bd7e7e2bb979ec2ca5 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 21 Sep 2025 17:40:42 +0200 Subject: [PATCH 203/279] [bugfix] Fix Transliterator for Lao char Change-Id: I0ae7ea4b62a3491ee75c2b2a9e50d2b1ea2e9175 --- pywikibot/userinterfaces/transliteration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pywikibot/userinterfaces/transliteration.py b/pywikibot/userinterfaces/transliteration.py index 5719bd0321..7e297d26be 100644 --- a/pywikibot/userinterfaces/transliteration.py +++ b/pywikibot/userinterfaces/transliteration.py @@ -1148,7 +1148,7 @@ def transliterate(self, char: str, default: str = '?', result = prev # Lao elif char == 'ຫ': - result = '' if next in 'ງຍນຣລຼຼວ' else 'h' + result = '' if succ in 'ງຍນຣລຼຼວ' else 'h' return result From 766718d3232ac9fb22851754b9a9b96eed8a0830 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 21 Sep 2025 18:31:14 +0200 Subject: [PATCH 204/279] Typing: Solve some mypy issues Change-Id: I61f3608565f7ad909a3e85412967bfdd55732c02 --- .pre-commit-config.yaml | 4 ++-- conftest.py | 5 +++-- pywikibot/backports.py | 4 ++-- pywikibot/tools/chars.py | 9 +++++---- pywikibot/tools/threading.py | 2 +- pywikibot/userinterfaces/buffer_interface.py | 2 +- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc0cae2e71..156023626b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -135,6 +135,6 @@ repos: pagegenerators/(__init__|_filters)| scripts/(?:i18n/)?__init__| site/(__init__|_basesite|_decorators|_interwikimap|_tokenwallet|_upload)| - tools/(_logging|_unidata|formatter)| - userinterfaces/(__init__|_interface_base|terminal_interface) + tools/(_deprecate|_logging|_unidata|chars|formatter)| + userinterfaces/(__init__|_interface_base|buffer_interface|terminal_interface|transliteration) )\.py$ diff --git a/conftest.py b/conftest.py index 5e92cd4c75..015579620a 100644 --- a/conftest.py +++ b/conftest.py @@ -26,8 +26,9 @@ r'scripts/(i18n/)?__init__|' r'site/(__init__|_basesite|_decorators|_interwikimap|' r'_tokenwallet|_upload)|' - r'tools/(_logging|_unidata|formatter)|' - r'userinterfaces/(__init__|_interface_base|terminal_interface)' + r'tools/(_deprecate|_logging|_unidata|chars|formatter)|' + r'userinterfaces/(__init__|_interface_base|buffer_interface|' + r'terminal_interface|transliteration)' r')\.py' ) diff --git a/pywikibot/backports.py b/pywikibot/backports.py index 00636aa45f..ca4d3c6991 100644 --- a/pywikibot/backports.py +++ b/pywikibot/backports.py @@ -20,8 +20,8 @@ # Placed here to omit circular import in tools -PYTHON_VERSION = sys.version_info[:3] -SPHINX_RUNNING = 'sphinx' in sys.modules +PYTHON_VERSION: tuple[int, int, int] = sys.version_info[:3] +SPHINX_RUNNING: bool = 'sphinx' in sys.modules # functools.cache if PYTHON_VERSION >= (3, 9): diff --git a/pywikibot/tools/chars.py b/pywikibot/tools/chars.py index 08d50e25ac..e251b97487 100644 --- a/pywikibot/tools/chars.py +++ b/pywikibot/tools/chars.py @@ -1,6 +1,6 @@ """Character based helper functions (not wiki-dependent).""" # -# (C) Pywikibot team, 2015-2024 +# (C) Pywikibot team, 2015-2025 # # Distributed under the terms of the MIT license. # @@ -8,6 +8,7 @@ import re from contextlib import suppress +from typing import cast from urllib.parse import unquote from pywikibot.backports import Iterable @@ -125,13 +126,13 @@ def url2string(title: str, if isinstance(encodings, str): return unquote(title, encodings, errors='strict') - first_exception = None + first_exception: BaseException | None = None for enc in encodings: try: return unquote(title, enc, errors='strict') except (UnicodeError, LookupError) as e: - if not first_exception: + if first_exception is None: first_exception = e # Couldn't convert, raise the first exception - raise first_exception + raise cast(BaseException, first_exception) diff --git a/pywikibot/tools/threading.py b/pywikibot/tools/threading.py index 522a461efb..83c40deaa6 100644 --- a/pywikibot/tools/threading.py +++ b/pywikibot/tools/threading.py @@ -69,7 +69,7 @@ def __init__(self, group=None, target=None, name: str = 'GeneratorThread', raise RuntimeError('No generator for ThreadedGenerator to run.') self.args, self.kwargs = args, kwargs super().__init__(group=group, name=name) - self.queue = queue.Queue(qsize) + self.queue: queue.Queue[Any] = queue.Queue(qsize) self.finished = threading.Event() def __iter__(self): diff --git a/pywikibot/userinterfaces/buffer_interface.py b/pywikibot/userinterfaces/buffer_interface.py index 22121e9373..31f6993fb0 100644 --- a/pywikibot/userinterfaces/buffer_interface.py +++ b/pywikibot/userinterfaces/buffer_interface.py @@ -30,7 +30,7 @@ def __init__(self) -> None: """Initialize the UI.""" super().__init__() - self._buffer = queue.Queue() + self._buffer: queue.Queue[Any] = queue.Queue() self.log_handler = logging.handlers.QueueHandler(self._buffer) self.log_handler.setLevel(VERBOSE if config.verbose_output else INFO) From 1673c27e150f30d7b755f7e524e57141df3b4d66 Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 25 Sep 2025 10:04:43 +0200 Subject: [PATCH 205/279] Tests: Fix TestBacklinks tests in site_generators_tests.py The tests fails because of the pages isn't only referenced as a redirect but also transcluded by a bot template. Remove these transclusion by with_template_inclusion=False setting Bug: T405449 Change-Id: I65d79528b942827df66c34dae7c088d952000386 --- tests/site_generators_tests.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index 8b38a9e1ec..063bb0c50f 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -1798,15 +1798,23 @@ def setUp(self) -> None: """Set up tests.""" super().setUp() self.page = pywikibot.Page(self.site, 'File:BoA – Woman.png') - self.backlinks = list(self.page.backlinks(follow_redirects=False, - filter_redirects=True, - total=5)) - self.references = list(self.page.getReferences(follow_redirects=True, - filter_redirects=True, - total=5)) - self.nofollow = list(self.page.getReferences(follow_redirects=False, - filter_redirects=True, - total=5)) + self.backlinks = list( + self.page.backlinks(follow_redirects=False, + filter_redirects=True, + total=5) + ) + self.references = list( + self.page.getReferences(follow_redirects=True, + filter_redirects=True, + with_template_inclusion=False, + total=5) + ) + self.nofollow = list( + self.page.getReferences(follow_redirects=False, + filter_redirects=True, + with_template_inclusion=False, + total=5) + ) def test_backlinks_redirects_length(self) -> None: """Test backlinks redirects length.""" From e3f3e4cfb48aff8ea086bd08173dd8d471464af8 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 23 Sep 2025 20:30:21 +0200 Subject: [PATCH 206/279] Typing: Solve some mypy issues Note: | operator for union types is available with Python 3.10 MyPy test are made with Python 3.9 Change-Id: I17facfd8d6697abd075695852d95cc1398865abf --- .pre-commit-config.yaml | 6 +++--- conftest.py | 7 ++++--- pyproject.toml | 2 +- pywikibot/_wbtypes.py | 20 +++++++++++++++----- pywikibot/bot.py | 4 +++- pywikibot/data/wikistats.py | 8 ++++---- pywikibot/date.py | 15 ++++++++------- pywikibot/i18n.py | 7 ++++--- pywikibot/page/_wikibase.py | 16 +++++++++------- pywikibot/plural.py | 5 +++-- pywikibot/site/_namespace.py | 20 ++++++++++++++++++++ 11 files changed, 74 insertions(+), 36 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 156023626b..10a958fc2a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -126,12 +126,12 @@ repos: # They should be also used in conftest.py to exclude them from non-voting mypy test. files: | (?x)^pywikibot/( - (__metadata__|backports|config|diff|echo|exceptions|fixes|logging|time)| + (__metadata__|backports|config|diff|echo|exceptions|fixes|logging|plural|time)| (comms|data|families|specialbots)/__init__| comms/eventstreams| - data/(api/(__init__|_optionset)|memento)| + data/(api/(__init__|_optionset)|memento|wikistats)| families/[a-z][a-z\d]+_family| - page/(__init__|_decorators|_revision)| + page/(__init__|_decorators|_page|_revision)| pagegenerators/(__init__|_filters)| scripts/(?:i18n/)?__init__| site/(__init__|_basesite|_decorators|_interwikimap|_tokenwallet|_upload)| diff --git a/conftest.py b/conftest.py index 015579620a..e33738aaea 100644 --- a/conftest.py +++ b/conftest.py @@ -16,12 +16,13 @@ EXCLUDE_PATTERN = re.compile( r'(?:' - r'(__metadata__|backports|config|diff|echo|exceptions|fixes|logging|time)|' + r'(__metadata__|backports|config|diff|echo|exceptions|fixes|logging|' + r'plural|time)|' r'(comms|data|families|specialbots)/__init__|' r'comms/eventstreams|' - r'data/(api/(__init__|_optionset)|memento)|' + r'data/(api/(__init__|_optionset)|memento|wikistats)|' r'families/[a-z][a-z\d]+_family|' - r'page/(__init__|_decorators|_revision)|' + r'page/(__init__|_decorators|_page|_revision)|' r'pagegenerators/(__init__|_filters)|' r'scripts/(i18n/)?__init__|' r'site/(__init__|_basesite|_decorators|_interwikimap|' diff --git a/pyproject.toml b/pyproject.toml index 3f3e66a98d..d37f51cb3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -187,7 +187,7 @@ extra_standard_library = ["tomllib"] [tool.mypy] -python_version = 3.9 +python_version = "3.9" enable_error_code = [ "ignore-without-code", ] diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py index 0aa2317008..37139313fd 100644 --- a/pywikibot/_wbtypes.py +++ b/pywikibot/_wbtypes.py @@ -28,10 +28,12 @@ if TYPE_CHECKING: + from typing import Union, cast + from pywikibot.site import APISite, BaseSite, DataSite - ItemPageStrNoneType = str | pywikibot.ItemPage | None - ToDecimalType = int | float | str | Decimal | None + ItemPageStrNoneType = Union[str, pywikibot.ItemPage, None] + ToDecimalType = Union[int, float, str, Decimal, None] __all__ = ( @@ -49,6 +51,8 @@ class WbRepresentation(abc.ABC): """Abstract class for Wikibase representations.""" + _items: tuple[str, ...] + @abc.abstractmethod def __init__(self) -> None: """Initializer.""" @@ -379,7 +383,7 @@ def __getitem__(self, key: str) -> int: return self.PRECISION[key] - def __iter__(self) -> Iterator[int]: + def __iter__(self) -> Iterator[str]: return iter(self.PRECISION) def __len__(self) -> int: @@ -401,6 +405,12 @@ class WbTime(WbRepresentation): :class:`pywikibot.Timestamp` and :meth:`fromTimestamp`. """ + month: int + day: int + hour: int + minute: int + second: int + PRECISION = _Precision() FORMATSTR = '{0:+012d}-{1:02d}-{2:02d}T{3:02d}:{4:02d}:{5:02d}Z' @@ -529,8 +539,8 @@ def __init__( if (isinstance(precision, int) and precision in self.PRECISION.values()): prec = precision - elif precision in self.PRECISION: - prec = self.PRECISION[precision] + elif isinstance(precision, str) and precision in self.PRECISION: + prec = self.PRECISION[cast(str, precision)] else: raise ValueError(f'Invalid precision: "{precision}"') diff --git a/pywikibot/bot.py b/pywikibot/bot.py index d3457ae2a8..b19730760d 100644 --- a/pywikibot/bot.py +++ b/pywikibot/bot.py @@ -190,9 +190,11 @@ class is mainly used for bots which work with Wikibase or together if TYPE_CHECKING: + from typing import Union + from pywikibot.site import BaseSite - AnswerType = Iterable[tuple[str, str] | Option] | Option + AnswerType = Union[Iterable[Union[tuple[str, str], Option]], Option] _GLOBAL_HELP = """ GLOBAL OPTIONS diff --git a/pywikibot/data/wikistats.py b/pywikibot/data/wikistats.py index d85a3710ad..e3570cf513 100644 --- a/pywikibot/data/wikistats.py +++ b/pywikibot/data/wikistats.py @@ -1,6 +1,6 @@ """Objects representing WikiStats API.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -122,10 +122,10 @@ def sorted(self, table: str, key: str, alphanumeric keys are sorted in normal way. :return: The sorted table """ - table = self.get(table) + data = self.get(table) # take the first entry to determine the sorting key - first_entry = table[0] + first_entry = data[0] if first_entry[key].isdigit(): def sort_key(d): return int(d[key]) reverse = reverse if reverse is not None else True @@ -133,7 +133,7 @@ def sort_key(d): return int(d[key]) def sort_key(d): return d[key] reverse = reverse if reverse is not None else False - return sorted(table, key=sort_key, reverse=reverse) + return sorted(data, key=sort_key, reverse=reverse) def languages_by_size(self, table: str): """Return ordered list of languages by size from WikiStats.""" diff --git a/pywikibot/date.py b/pywikibot/date.py index 9c2a01ea4e..886efebd9c 100644 --- a/pywikibot/date.py +++ b/pywikibot/date.py @@ -30,16 +30,17 @@ if TYPE_CHECKING: - tuplst_type = list[tuple[Callable[[int | str], Any], - Callable[[int | str], bool]]] - encf_type = Callable[[int], int | Sequence[int]] + from typing import Union + tuplst_type = list[tuple[Callable[[Union[int, str]], Any], + Callable[[Union[int, str]], bool]]] + encf_type = Callable[[int], Union[int, Sequence[int]]] decf_type = Callable[[Sequence[int]], int] # decoders are three value tuples, with an optional fourth to represent a # required number of digits - decoder_type = ( - tuple[str, Callable[[int], str], Callable[[str], int]] - | tuple[str, Callable[[int], str], Callable[[str], int], int] - ) + decoder_type = Union[ + tuple[str, Callable[[int], str], Callable[[str], int]], + tuple[str, Callable[[int], str], Callable[[str], int], int] + ] # # Different collections of well known formats diff --git a/pywikibot/i18n.py b/pywikibot/i18n.py index a7f17de5d3..62d5b27574 100644 --- a/pywikibot/i18n.py +++ b/pywikibot/i18n.py @@ -29,6 +29,7 @@ from contextlib import suppress from pathlib import Path from textwrap import fill +from typing import Any import pywikibot from pywikibot import __url__, config @@ -554,9 +555,9 @@ def __len__(self) -> int: def translate(code: str | pywikibot.site.BaseSite, - xdict: str | Mapping[str, str], + xdict: str | Mapping[str, Any], parameters: Mapping[str, int] | None = None, - fallback: bool | Iterable[str] = False) -> str | None: + fallback: bool | Iterable[str] = False) -> Any | None: """Return the most appropriate localization from a localization dict. Given a site code and a dictionary, returns the dictionary's value @@ -591,7 +592,7 @@ def translate(code: str | pywikibot.site.BaseSite, :param parameters: For passing (plural) parameters :param fallback: Try an alternate language code. If it's iterable it'll also try those entries and choose the first match. - :return: the localized string + :return: the localized value, usually a string :raise IndexError: If the language supports and requires more plurals than defined for the given PLURAL pattern. :raise KeyError: No fallback key found if fallback is not False diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py index b328e45a96..1ef035b864 100644 --- a/pywikibot/page/_wikibase.py +++ b/pywikibot/page/_wikibase.py @@ -64,15 +64,17 @@ ) if TYPE_CHECKING: - LANGUAGE_IDENTIFIER = str | pywikibot.site.APISite + from typing import Union + LANGUAGE_IDENTIFIER = Union[str, pywikibot.site.APISite] ALIASES_TYPE = dict[LANGUAGE_IDENTIFIER, list[str]] LANGUAGE_TYPE = dict[LANGUAGE_IDENTIFIER, str] - SITELINK_TYPE = ( - pywikibot.page.BasePage - | pywikibot.page.BaseLink - | dict[str, str] - ) - ENTITY_DATA_TYPE = dict[str, LANGUAGE_TYPE | ALIASES_TYPE | SITELINK_TYPE] + SITELINK_TYPE = Union[ + pywikibot.page.BasePage, + pywikibot.page.BaseLink, + dict[str, str] + ] + ENTITY_DATA_TYPE = dict[str, + Union[LANGUAGE_TYPE, ALIASES_TYPE, SITELINK_TYPE]] class WikibaseEntity: diff --git a/pywikibot/plural.py b/pywikibot/plural.py index 8fb0e54f2c..d353bcf7c1 100644 --- a/pywikibot/plural.py +++ b/pywikibot/plural.py @@ -6,11 +6,12 @@ # from __future__ import annotations -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING if TYPE_CHECKING: - PluralRule = dict[str, int | Callable[[int], bool | int]] + from typing import Callable, Union + PluralRule = dict[str, Union[int, Callable[[int], Union[bool, int]]]] plural_rules: dict[str, PluralRule] = { '_default': {'nplurals': 2, 'plural': lambda n: (n != 1)}, diff --git a/pywikibot/site/_namespace.py b/pywikibot/site/_namespace.py index f82c03bcd9..e28fd5538b 100644 --- a/pywikibot/site/_namespace.py +++ b/pywikibot/site/_namespace.py @@ -91,6 +91,26 @@ class Namespace(Iterable, ComparableMixin, metaclass=MetaNamespace): metaclass from :class:`MetaNamespace` """ + # Hints of BuiltinNamespace types added with initializer + MEDIA: int + SPECIAL: int + MAIN: int + TALK: int + USER: int + USER_TALK: int + PROJECT: int + PROJECT_TALK: int + FILE: int + FILE_TALK: int + MEDIAWIKI: int + MEDIAWIKI_TALK: int + TEMPLATE: int + TEMPLATE_TALK: int + HELP: int + HELP_TALK: int + CATEGORY: int + CATEGORY_TALK: int + def __init__(self, id, canonical_name: str | None = None, custom_name: str | None = None, From 2b9f6aa592464a92fab3fd606cc9e5ce187fa270 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 25 Sep 2025 14:28:09 +0200 Subject: [PATCH 207/279] Update git submodules * Update scripts/i18n from branch 'master' to d64502b4d1ee170bab6fe21e5d4e80dfdf82af08 - Localisation updates from https://translatewiki.net. Change-Id: I35d030e0c364e4e27436ba025b1abb3d96cb3886 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index feefe6eea3..d64502b4d1 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit feefe6eea35cf5f2af8ee11d10a68e97720d1e5b +Subproject commit d64502b4d1ee170bab6fe21e5d4e80dfdf82af08 From 21a22a3f4f4a2b703f893c72ba2f1be5a9557507 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 27 Sep 2025 17:17:23 +0200 Subject: [PATCH 208/279] IMPT: daemonize improvements - enforce keyword-only parameters and add deprecation warning - raise a NotImplementedError if called under non-posix os - suppress OSError when closing standard streams - Update and expand docstring - add daemonize to pre-commit MyPy tests Change-Id: I50ae24b2567c092add345a842ae0c2035a2be0e4 --- .pre-commit-config.yaml | 4 +- ROADMAP.rst | 4 ++ conftest.py | 4 +- pywikibot/daemonize.py | 85 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10a958fc2a..872128b03e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.1 + rev: v0.13.2 hooks: - id: ruff-check alias: ruff @@ -126,7 +126,7 @@ repos: # They should be also used in conftest.py to exclude them from non-voting mypy test. files: | (?x)^pywikibot/( - (__metadata__|backports|config|diff|echo|exceptions|fixes|logging|plural|time)| + (__metadata__|backports|config|daemonize|diff|echo|exceptions|fixes|logging|plural|time)| (comms|data|families|specialbots)/__init__| comms/eventstreams| data/(api/(__init__|_optionset)|memento|wikistats)| diff --git a/ROADMAP.rst b/ROADMAP.rst index 69367fdcba..3e5439f884 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,6 +1,8 @@ Current Release Changes ======================= +* Positional arguments of :func:`daemonize()` are deprecated and must + be given as keyword arguments. * Return :meth:`bot.BaseBot.userPut` result with :meth:`AutomaticTWSummaryBot.put_current() ` method @@ -11,6 +13,8 @@ Deprecations Pending removal in Pywikibot 13 ------------------------------- +* 10.6.0: Positional arguments of :func:`daemonize()` are deprecated and must + be given as keyword arguments. * 10.5.0: Accessing the fallback '*' keys in 'languages', 'namespaces', 'namespacealiases', and 'skins' properties of :attr:`APISite.siteinfo` are deprecated and will be removed. diff --git a/conftest.py b/conftest.py index e33738aaea..d34593e5e8 100644 --- a/conftest.py +++ b/conftest.py @@ -16,8 +16,8 @@ EXCLUDE_PATTERN = re.compile( r'(?:' - r'(__metadata__|backports|config|diff|echo|exceptions|fixes|logging|' - r'plural|time)|' + r'(__metadata__|backports|config|daemonize|diff|echo|exceptions|fixes|' + r'logging|plural|time)|' r'(comms|data|families|specialbots)/__init__|' r'comms/eventstreams|' r'data/(api/(__init__|_optionset)|memento|wikistats)|' diff --git a/pywikibot/daemonize.py b/pywikibot/daemonize.py index ea90c16a99..9bdc9e532b 100644 --- a/pywikibot/daemonize.py +++ b/pywikibot/daemonize.py @@ -1,4 +1,54 @@ -"""Module to daemonize the current process on Unix.""" +"""Module to daemonize the current process on POSIX systems. + +This module provides a function :func:`daemonize` to turn the current +Python process into a background daemon process on POSIX-compatible +operating systems (Linux, macOS, FreeBSD) but not on not WASI Android or +iOS. It uses the standard double-fork technique to detach the process +from the controlling terminal and optionally closes or redirects +standard streams. + +Double-fork diagram:: + + Original process (parent) + ├── fork() → creates first child + │ └─ Parent exits via os._exit() → returns control to terminal + │ + └── First child + ├── os.setsid() → becomes session leader (detaches from terminal) + ├── fork() → creates second child (grandchild) + │ └─ First child exits → ensures grandchild is NOT a session leader + │ + └── Second child (Daemon) + ├── is_daemon = True + ├── Optionally close/redirect standard streams + ├── Optionally change working directory + └── # Daemon continues here + while True: + do_background_work() + +The "while True" loop represents the main work of the daemon: + +- It runs indefinitely in the background +- Performs tasks such as monitoring files, processing data, or logging +- Everything after :func:`daemonize` runs only in the daemon process + +Example usage: + + .. code-block:: Python + + import time + from pywikibot.daemonize import daemonize + + def background_task(): + while True: + print("Daemon is working...") + time.sleep(5) + + daemonize() + + # This code only runs in the daemon process + background_task() +""" # # (C) Pywikibot team, 2007-2025 # @@ -7,11 +57,15 @@ from __future__ import annotations import os +import platform import stat import sys +from contextlib import suppress from enum import IntEnum from pathlib import Path +from pywikibot.tools import deprecate_positionals + class StandardFD(IntEnum): @@ -25,7 +79,9 @@ class StandardFD(IntEnum): is_daemon = False -def daemonize(close_fd: bool = True, +@deprecate_positionals(since='10.6.0') +def daemonize(*, + close_fd: bool = True, chdir: bool = True, redirect_std: str | None = None) -> None: """Daemonize the current process. @@ -33,11 +89,27 @@ def daemonize(close_fd: bool = True, Only works on POSIX compatible operating systems. The process will fork to the background and return control to terminal. + .. versionchanged:: 10.6 + raises NotImplementedError instead of AttributeError if daemonize + is not available for the given platform. Parameters must be given + as keyword-only arguments. + + .. caution:: + Do not use it in multithreaded scripts or in a subinterpreter. + :param close_fd: Close the standard streams and replace them by /dev/null :param chdir: Change the current working directory to / :param redirect_std: Filename to redirect stdout and stdin to + :raises RuntimeError: Must not be run in a subinterpreter + :raises NotImplementedError: Daemon mode not supported on given + platform """ + # platform check for MyPy + if not hasattr(os, 'fork') or sys.platform == 'win32': + msg = f'Daemon mode not supported on {platform.system()}' + raise NotImplementedError(msg) + # Fork away if not os.fork(): # Become session leader @@ -50,9 +122,12 @@ def daemonize(close_fd: bool = True, global is_daemon is_daemon = True + # Optionally close and redirect standard streams if close_fd: for fd in StandardFD: - os.close(fd) + with suppress(OSError): + os.close(fd) + os.open('/dev/null', os.O_RDWR) if redirect_std: @@ -67,9 +142,11 @@ def daemonize(close_fd: bool = True, os.dup2(StandardFD.STDIN, StandardFD.STDOUT) os.dup2(StandardFD.STDOUT, StandardFD.STDERR) + # Optionally change working directory if chdir: os.chdir('/') - return + + return # Daemon continues here # Write out the pid path = Path(Path(sys.argv[0]).name).with_suffix('.pid') From 39fcb59aaf122e90f86b29a53b7b87e793ce30af Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 27 Sep 2025 18:02:09 +0200 Subject: [PATCH 209/279] MyPy: solve MyPy issues in cosmetic_changes.py and titletranslate.py Change-Id: I6d2643d47fd4760d061eef8e1d13d4ea9c7418a7 --- .pre-commit-config.yaml | 2 +- conftest.py | 4 ++-- pywikibot/cosmetic_changes.py | 6 ++---- pywikibot/titletranslate.py | 12 ++++++------ 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 872128b03e..6e18061091 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -126,7 +126,7 @@ repos: # They should be also used in conftest.py to exclude them from non-voting mypy test. files: | (?x)^pywikibot/( - (__metadata__|backports|config|daemonize|diff|echo|exceptions|fixes|logging|plural|time)| + (__metadata__|backports|config|cosmetic_changes|daemonize|diff|echo|exceptions|fixes|logging|plural|time|titletranslate)| (comms|data|families|specialbots)/__init__| comms/eventstreams| data/(api/(__init__|_optionset)|memento|wikistats)| diff --git a/conftest.py b/conftest.py index d34593e5e8..a314ee8863 100644 --- a/conftest.py +++ b/conftest.py @@ -16,8 +16,8 @@ EXCLUDE_PATTERN = re.compile( r'(?:' - r'(__metadata__|backports|config|daemonize|diff|echo|exceptions|fixes|' - r'logging|plural|time)|' + r'(__metadata__|backports|config|cosmetic_changes|daemonize|diff|echo|' + r'exceptions|fixes|logging|plural|time|titletranslate)|' r'(comms|data|families|specialbots)/__init__|' r'comms/eventstreams|' r'data/(api/(__init__|_optionset)|memento|wikistats)|' diff --git a/pywikibot/cosmetic_changes.py b/pywikibot/cosmetic_changes.py index af94592777..2d87ed6946 100644 --- a/pywikibot/cosmetic_changes.py +++ b/pywikibot/cosmetic_changes.py @@ -369,10 +369,8 @@ def standardizePageFooter(self, text: str) -> str: if not self.talkpage: subpage = False if self.template: - loc = None - with suppress(TypeError): - _tmpl, loc = i18n.translate(self.site.code, moved_links) - if loc is not None and loc in self.title: + loc = i18n.translate(self.site.code, moved_links) + if loc is not None and loc[1] in self.title: subpage = True # get interwiki diff --git a/pywikibot/titletranslate.py b/pywikibot/titletranslate.py index 16720042cc..1d381be5d3 100644 --- a/pywikibot/titletranslate.py +++ b/pywikibot/titletranslate.py @@ -1,6 +1,6 @@ """Title translate module.""" # -# (C) Pywikibot team, 2003-2024 +# (C) Pywikibot team, 2003-2025 # # Distributed under the terms of the MIT license. # @@ -46,7 +46,7 @@ def translate( for h in hints: # argument may be given as -hint:xy where xy is a language code - codes, _, newname = h.partition(':') + code, _, newname = h.partition(':') if not newname: # if given as -hint:xy or -hint:xy:, assume that there should # be a page in language xy with the same title as the page @@ -55,12 +55,12 @@ def translate( continue newname = page.title(with_ns=False, without_brackets=removebrackets) - if codes.isdigit(): - codes = site.family.languages_by_size[:int(codes)] - elif codes == 'all': + if code.isdigit(): + codes = site.family.languages_by_size[:int(code)] + elif code == 'all': codes = list(site.family.codes) else: - codes = site.family.language_groups.get(codes, codes.split(',')) + codes = site.family.language_groups.get(code, code.split(',')) for newcode in codes: if newcode in site.codes: From f6b51bbf98f3d92aa03d04efe73c8b13049b3311 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 29 Sep 2025 14:28:41 +0200 Subject: [PATCH 210/279] Update git submodules * Update scripts/i18n from branch 'master' to 1b771c1b8f526cd4c9f4baec614cb0ff7b5db879 - Localisation updates from https://translatewiki.net. Change-Id: Iaaa6ec7803e0ec4057288d7e45226d92c0602858 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index d64502b4d1..1b771c1b8f 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit d64502b4d1ee170bab6fe21e5d4e80dfdf82af08 +Subproject commit 1b771c1b8f526cd4c9f4baec614cb0ff7b5db879 From 4bdeb54ce62474664319614ae764385e7ccc8532 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 5 Oct 2025 17:22:20 +0200 Subject: [PATCH 211/279] [bugfix] finally reset messages in DeprecationTestCase.assertOneDeprecation Bug: T406428 Change-Id: I03cbae772e30c89460e4e546a8aaf6522e1292bb --- tests/aspects.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/aspects.py b/tests/aspects.py index b691abc93f..e980ea90f8 100644 --- a/tests/aspects.py +++ b/tests/aspects.py @@ -1666,13 +1666,15 @@ def assertOneDeprecationParts(self, deprecated=None, instead=None, def assertOneDeprecation(self, msg=None, count=1) -> None: """Assert that exactly one deprecation message happened and reset.""" - self.assertDeprecation(msg) - # This is doing such a weird structure, so that it shows any other - # deprecation message from the set. - self.assertCountEqual(set(self.deprecation_messages), - [self.deprecation_messages[0]]) - self.assertLength(self.deprecation_messages, count) - self._reset_messages() + try: + self.assertDeprecation(msg) + # This is doing such a weird structure, so that it shows any other + # deprecation message from the set. + self.assertCountEqual(set(self.deprecation_messages), + [self.deprecation_messages[0]]) + self.assertLength(self.deprecation_messages, count) + finally: + self._reset_messages() def assertNoDeprecation(self, msg=None) -> None: """Assert that no deprecation warning happened.""" From 5cba4c081b5a4636f21e2af208f85243cc4c9eb4 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 3 Oct 2025 19:07:06 +0200 Subject: [PATCH 212/279] IMPR: rename deprecate_positional to deprecated_signature Also - deprecate keyword arguments used for positional only parameters (PEP 570) - added some tests Change-Id: I3d54e0e26d17aea0ff8c973869cf9fc57f63b2c2 --- pywikibot/_wbtypes.py | 12 +-- pywikibot/daemonize.py | 4 +- pywikibot/page/_basepage.py | 8 +- pywikibot/site/_apisite.py | 4 +- pywikibot/site/_generators.py | 4 +- pywikibot/throttle.py | 4 +- pywikibot/tools/__init__.py | 4 +- pywikibot/tools/_deprecate.py | 130 ++++++++++++++++++++++++++------- tests/tools_deprecate_tests.py | 89 ++++++++++++---------- 9 files changed, 175 insertions(+), 84 deletions(-) diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py index 37139313fd..89dea48a51 100644 --- a/pywikibot/_wbtypes.py +++ b/pywikibot/_wbtypes.py @@ -21,7 +21,7 @@ from pywikibot.backports import Iterator from pywikibot.time import Timestamp from pywikibot.tools import ( - deprecate_positionals, + deprecated_signature, issue_deprecation_warning, remove_last_args, ) @@ -113,7 +113,7 @@ class Coordinate(WbRepresentation): _items = ('lat', 'lon', 'entity') - @deprecate_positionals(since='10.4.0') + @deprecated_signature(since='10.4.0') def __init__( self, lat: float, @@ -326,7 +326,7 @@ def precisionToDim(self) -> int | None: ) return self._dim - @deprecate_positionals(since='10.4.0') + @deprecated_signature(since='10.4.0') def get_globe_item(self, repo: DataSite | None = None, *, lazy_load: bool = False) -> pywikibot.ItemPage: """Return the ItemPage corresponding to the globe. @@ -436,7 +436,7 @@ class WbTime(WbRepresentation): _timestr_re = re.compile( r'([-+]?\d{1,16})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z') - @deprecate_positionals(since='10.4.0') + @deprecated_signature(since='10.4.0') def __init__( self, year: int, @@ -651,7 +651,7 @@ def equal_instant(self, other: WbTime) -> bool: return self._getSecondsAdjusted() == other._getSecondsAdjusted() @classmethod - @deprecate_positionals(since='10.4.0') + @deprecated_signature(since='10.4.0') def fromTimestr( cls, datetimestr: str, @@ -703,7 +703,7 @@ def fromTimestr( timezone=timezone, calendarmodel=calendarmodel, site=site) @classmethod - @deprecate_positionals(since='10.4.0') + @deprecated_signature(since='10.4.0') def fromTimestamp( cls, timestamp: Timestamp, diff --git a/pywikibot/daemonize.py b/pywikibot/daemonize.py index 9bdc9e532b..a00c131416 100644 --- a/pywikibot/daemonize.py +++ b/pywikibot/daemonize.py @@ -64,7 +64,7 @@ def background_task(): from enum import IntEnum from pathlib import Path -from pywikibot.tools import deprecate_positionals +from pywikibot.tools import deprecated_signature class StandardFD(IntEnum): @@ -79,7 +79,7 @@ class StandardFD(IntEnum): is_daemon = False -@deprecate_positionals(since='10.6.0') +@deprecated_signature(since='10.6.0') def daemonize(*, close_fd: bool = True, chdir: bool = True, diff --git a/pywikibot/page/_basepage.py b/pywikibot/page/_basepage.py index f10c1d1f3f..93af7cf31b 100644 --- a/pywikibot/page/_basepage.py +++ b/pywikibot/page/_basepage.py @@ -38,9 +38,9 @@ from pywikibot.tools import ( ComparableMixin, cached, - deprecate_positionals, deprecated, deprecated_args, + deprecated_signature, first_upper, ) @@ -1457,7 +1457,7 @@ def put(self, newtext: str, force=force, asynchronous=asynchronous, callback=callback, **kwargs) - @deprecate_positionals(since='10.4.0') + @deprecated_signature(since='10.4.0') def watch( self, *, unwatch: bool = False, @@ -1660,7 +1660,7 @@ def data_item(self) -> pywikibot.page.ItemPage: """Convenience function to get the Wikibase item of a page.""" return pywikibot.ItemPage.fromPage(self) - @deprecate_positionals(since='9.2') + @deprecated_signature(since='9.2') def templates(self, *, content: bool = False, @@ -1705,7 +1705,7 @@ def templates(self, return list(self._templates) - @deprecate_positionals(since='9.2') + @deprecated_signature(since='9.2') def itertemplates( self, total: int | None = None, diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py index ec61c45de6..a79681454e 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -73,8 +73,8 @@ MediaWikiVersion, cached, deprecate_arg, - deprecate_positionals, deprecated, + deprecated_signature, issue_deprecation_warning, merge_unique_dicts, normalize_username, @@ -2995,7 +2995,7 @@ def unblockuser( return req.submit() @need_right('editmywatchlist') - @deprecate_positionals(since='10.4.0') + @deprecated_signature(since='10.4.0') def watch( self, pages: BasePage | str | list[BasePage | str], diff --git a/pywikibot/site/_generators.py b/pywikibot/site/_generators.py index 9901495419..8bdc721c4b 100644 --- a/pywikibot/site/_generators.py +++ b/pywikibot/site/_generators.py @@ -26,7 +26,7 @@ ) from pywikibot.site._decorators import need_right from pywikibot.site._namespace import NamespaceArgType -from pywikibot.tools import deprecate_arg, deprecate_positionals, is_ip_address +from pywikibot.tools import deprecate_arg, deprecated_signature, is_ip_address from pywikibot.tools.itertools import filter_unique @@ -925,7 +925,7 @@ def page_extlinks( for linkdata in pageitem['extlinks']: yield linkdata['*'] - @deprecate_positionals(since='10.4.0') + @deprecated_signature(since='10.4.0') def allpages( self, start: str = '!', *, diff --git a/pywikibot/throttle.py b/pywikibot/throttle.py index 9d8c7da11d..eb270fbede 100644 --- a/pywikibot/throttle.py +++ b/pywikibot/throttle.py @@ -27,7 +27,7 @@ import pywikibot from pywikibot import config from pywikibot.backports import Counter as CounterType -from pywikibot.tools import deprecate_positionals, deprecated, deprecated_args +from pywikibot.tools import deprecated, deprecated_args, deprecated_signature FORMAT_LINE = '{module_id} {pid} {time} {site}\n' @@ -343,7 +343,7 @@ def wait(seconds: int | float) -> None: time.sleep(seconds) @deprecated_args(requestsize=None) # since: 10.3.0 - @deprecate_positionals(since='10.3.0') + @deprecated_signature(since='10.3.0') def __call__(self, *, requestsize: int = 1, write: bool = False) -> None: """Apply throttling based on delay rules and request type. diff --git a/pywikibot/tools/__init__.py b/pywikibot/tools/__init__.py index a39cb519c7..bfc8da124c 100644 --- a/pywikibot/tools/__init__.py +++ b/pywikibot/tools/__init__.py @@ -29,9 +29,9 @@ add_decorated_full_name, add_full_name, deprecate_arg, - deprecate_positionals, deprecated, deprecated_args, + deprecated_signature, get_wrapper_depth, issue_deprecation_warning, manage_wrapping, @@ -55,9 +55,9 @@ 'add_decorated_full_name', 'add_full_name', 'deprecate_arg', - 'deprecate_positionals', 'deprecated', 'deprecated_args', + 'deprecated_signature', 'get_wrapper_depth', 'issue_deprecation_warning', 'manage_wrapping', diff --git a/pywikibot/tools/_deprecate.py b/pywikibot/tools/_deprecate.py index 4f6ffe96e6..e72f514197 100644 --- a/pywikibot/tools/_deprecate.py +++ b/pywikibot/tools/_deprecate.py @@ -443,28 +443,35 @@ def wrapper(*__args, **__kw): return decorator -def deprecate_positionals(since: str = ''): - """Decorator for methods that issues warnings for positional arguments. +def deprecated_signature(since: str = ''): + """Decorator handling deprecated changes in function or method signatures. - This decorator allows positional arguments after keyword-only - argument syntax (:pep:`3102`) but throws a ``FutureWarning``. It - automatically maps the provided positional arguments to their - corresponding keyword-only parameters before invoking the decorated - method. + This decorator supports: - The intended use is during a deprecation period in which certain - parameters should be passed as keyword-only, allowing legacy calls - to continue working with a warning rather than immediately raising a - ``TypeError``. + - Deprecation of positional arguments that have been converted to + keyword-only parameters. + - Detection of invalid keyword usage for positional-only parameters. - .. important:: This decorator is only supported for instance or - class methods. It does not work for standalone functions. + Positional-only parameters (introduced in :pep:`570`) must be passed + positionally. If such parameters are passed as keyword arguments, + this decorator will emit a ``FutureWarning`` and automatically remap + them to positional arguments for backward compatibility. + + It allows positional arguments after keyword-only syntax (:pep:`3102`) + but emits a ``FutureWarning``. Positional arguments that are now + keyword-only are automatically mapped to their corresponding + keyword parameters before the decorated function or method is + invoked. + + The intended use is during a deprecation period, allowing legacy + calls to continue working with a warning instead of raising a + ``TypeError`` immediately. Example: .. code-block:: python - @deprecate_positionals(since='9.2.0') + @deprecated_signature(since='10.6.0') def f(posarg, *, kwarg): ... @@ -473,18 +480,27 @@ def f(posarg, *, kwarg): This function call passes but throws a ``FutureWarning``. Without the decorator, a ``TypeError`` would be raised. - .. caution:: The decorated function must not accept ``*args``. The - sequence of keyword-only arguments must match the sequence of the - old positional parameters, otherwise argument assignment will - fail. + .. note:: + If the parameter name was changed, use :func:`deprecated_args` + first. + + .. caution:: + The decorated function must not accept ``*args``. The order of + keyword-only arguments must match the order of the old positional + parameters; otherwise, argument assignment may fail. .. versionadded:: 9.2 .. versionchanged:: 10.4 Raises ``ValueError`` if method has a ``*args`` parameter. - - :param since: Mandatory version string indicating when certain - positional parameters were deprecated - :raises ValueError: If the method has an *args parameter. + .. versionchanged:: 10.6 + Renamed from ``deprecate_positionals``. Adds handling of + positional-only parameters and emits warnings if they are passed + as keyword arguments. + + :param since: Mandatory version string indicating when signature + changed. + :raises TypeError: If required positional arguments are missing. + :raises ValueError: If the method has an ``*args`` parameter. """ def decorator(func): """Outer wrapper. Inspect the parameters of *func*. @@ -502,10 +518,64 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: :return: the value returned by the decorated function or method """ + # 1. fix deprecated positional-only usage + pos_only_in_kwargs = { + name: kwargs[name] + for name, p in params.items() + if p.kind == const.POSITIONAL_ONLY and name in kwargs + } + + if pos_only_in_kwargs: + new_args: list[Any] = [] + args_repr = [] # build representation for deprecation warning + idx = 0 # index for args + + for name in arg_keys: + param = params[name] + + if param.kind != const.POSITIONAL_ONLY: + # append remaining POSITIONAL_OR_KEYWORD arguments + new_args.extend(args[idx:]) + break + + if name in pos_only_in_kwargs: + # Value was passed as keyword → use it + value = kwargs.pop(name) + args_repr.append(repr(value)) + elif idx < len(args): + # Value from original args + value = args[idx] + idx += 1 + # Add ellipsis once for original args + if name not in ('cls', 'self') and ( + not args_repr or args_repr[-1] != '...'): + args_repr.append('...') + elif param.default is not param.empty: + # Value from default → show actual value + value = param.default + args_repr.append(repr(value)) + else: + raise TypeError( + f'Missing required positional argument: {name}' + ) + + new_args.append(value) + + args = tuple(new_args) + + args_str = ', '.join(args_repr) + issue_deprecation_warning( + f'Passing positional-only arguments as keywords to ' + f"{func.__qualname__}(): {', '.join(pos_only_in_kwargs)}", + f'positional arguments like {func.__name__}({args_str})', + since=since + ) + + # 2. warn for deprecated keyword-only usage as positional if len(args) > positionals: replace_args = list(zip(arg_keys[positionals:], args[positionals:])) - pos_args = "', '".join(name for name, arg in replace_args) + pos_args = "', '".join(name for name, _ in replace_args) keyw_args = ', '.join(f'{name}={arg!r}' for name, arg in replace_args) issue_deprecation_warning( @@ -520,17 +590,25 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return func(*args, **kwargs) sig = inspect.signature(func) + params = sig.parameters arg_keys = list(sig.parameters) + const = inspect.Parameter # find the first KEYWORD_ONLY index + positionals = 0 for positionals, key in enumerate(arg_keys): - if sig.parameters[key].kind == inspect.Parameter.VAR_POSITIONAL: + kind = params[key].kind + + # disallow *args entirely + if kind == const.VAR_POSITIONAL: raise ValueError( f'{func.__qualname__} must not have *{key} parameter') - if sig.parameters[key].kind in (inspect.Parameter.KEYWORD_ONLY, - inspect.Parameter.VAR_KEYWORD): + # stop counting when we reach keyword-only or **kwargs + if kind in (const.KEYWORD_ONLY, const.VAR_KEYWORD): break + else: + positionals += 1 # all were positional, no keyword found return wrapper diff --git a/tests/tools_deprecate_tests.py b/tests/tools_deprecate_tests.py index 728bb6f7c2..5657ea3f07 100755 --- a/tests/tools_deprecate_tests.py +++ b/tests/tools_deprecate_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for deprecation tools.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -15,9 +15,9 @@ PYTHON_VERSION, add_full_name, deprecate_arg, - deprecate_positionals, deprecated, deprecated_args, + deprecated_signature, remove_last_args, ) from tests.aspects import DeprecationTestCase @@ -154,9 +154,9 @@ def deprecated_all2(foo): return foo -@deprecate_positionals() -def positionals_test_function(foo: str, *, - bar: int, baz: str = '') -> tuple[int, str]: +@deprecated_signature() +def positionals_test_function(foo: str, /, *, + bar: int, baz: str = '') -> tuple[str, int]: """Deprecating positional parameters.""" return foo + baz, bar ** 2 @@ -240,9 +240,9 @@ def deprecated_all2(self, foo): """Deprecating last positional parameter.""" return foo - @deprecate_positionals() - def test_method(self, foo: str, *, - bar: int = 5, baz: str = '') -> tuple[int, str]: + @deprecated_signature() + def test_method(self, foo: str, /, *, + bar: int = 5, baz: str = '') -> tuple[str, int]: """Deprecating positional parameters.""" return foo + baz, bar ** 2 @@ -613,81 +613,94 @@ def test_method_remove_last_args(self) -> None: " The value(s) provided for 'bar' have been dropped." ) - def test_deprecate_positionals(self) -> None: - """Test deprecation of positional parameters.""" - msg = ('Passing {param} as positional argument(s) to {func}() is ' - 'deprecated; use keyword arguments like {instead} instead.') + def test_deprecated_signature(self) -> None: + """Test deprecation of parameters signature.""" + msg1 = ('Passing {param} as positional argument(s) to {func}() is ' + 'deprecated; use keyword arguments like {instead} instead.') + msg2 = ( + 'Passing positional-only arguments as keywords to {qual}(): ' + 'foo is deprecated; ' + "use positional arguments like {func}('Pywiki') instead." + ) f = DeprecatedMethodClass().test_method - func = 'DeprecatedMethodClass.test_method' + qual = f.__qualname__ + func = f.__name__ with self.subTest(test=1): rv1, rv2 = f('Pywiki', 1, 'bot') self.assertEqual(rv1, 'Pywikibot') self.assertEqual(rv2, 1) - self.assertOneDeprecation(msg.format(param="'bar', 'baz'", - func=func, - instead="bar=1, baz='bot'")) + self.assertOneDeprecation(msg1.format(param="'bar', 'baz'", + func=qual, + instead="bar=1, baz='bot'")) with self.subTest(test=2): rv1, rv2 = f('Pywiki', 2) self.assertEqual(rv1, 'Pywiki') self.assertEqual(rv2, 4) - self.assertOneDeprecation(msg.format(param="'bar'", - func=func, - instead='bar=2')) + self.assertOneDeprecation(msg1.format(param="'bar'", + func=qual, + instead='bar=2')) with self.subTest(test=3): rv1, rv2 = f('Pywiki', 3, baz='bot') self.assertEqual(rv1, 'Pywikibot') self.assertEqual(rv2, 9) - self.assertOneDeprecation(msg.format(param="'bar'", - func=func, - instead='bar=3')) + self.assertOneDeprecation(msg1.format(param="'bar'", + func=qual, + instead='bar=3')) with self.subTest(test=4): - rv1, rv2 = f('Pywiki', bar=4) + rv1, rv2 = f(foo='Pywiki') self.assertEqual(rv1, 'Pywiki') - self.assertEqual(rv2, 16) - self.assertNoDeprecation() + self.assertEqual(rv2, 25) + self.assertOneDeprecation(msg2.format(qual=qual, func=func)) with self.subTest(test=5): - rv1, rv2 = f(foo='Pywiki') + rv1, rv2 = f('Pywiki', bar=5) self.assertEqual(rv1, 'Pywiki') self.assertEqual(rv2, 25) self.assertNoDeprecation() f = positionals_test_function - func = 'positionals_test_function' + func = f.__name__ + qual = f.__qualname__ - with self.subTest(test=6): + with self.subTest(test=1): rv1, rv2 = f('Pywiki', 6, 'bot') self.assertEqual(rv1, 'Pywikibot') self.assertEqual(rv2, 36) - self.assertOneDeprecation(msg.format(param="'bar', 'baz'", - func=func, - instead="bar=6, baz='bot'")) + self.assertOneDeprecation(msg1.format(param="'bar', 'baz'", + func=qual, + instead="bar=6, baz='bot'")) with self.subTest(test=7): rv1, rv2 = f('Pywiki', 7) self.assertEqual(rv1, 'Pywiki') self.assertEqual(rv2, 49) - self.assertOneDeprecation(msg.format(param="'bar'", - func=func, - instead='bar=7')) + self.assertOneDeprecation(msg1.format(param="'bar'", + func=qual, + instead='bar=7')) with self.subTest(test=8): rv1, rv2 = f('Pywiki', 8, baz='bot') self.assertEqual(rv1, 'Pywikibot') self.assertEqual(rv2, 64) - self.assertOneDeprecation(msg.format(param="'bar'", - func=func, - instead='bar=8')) + self.assertOneDeprecation(msg1.format(param="'bar'", + func=qual, + instead='bar=8')) with self.subTest(test=9): - rv1, rv2 = f('Pywiki', bar=9) + rv1, rv2 = f(foo='Pywiki', bar=9) self.assertEqual(rv1, 'Pywiki') self.assertEqual(rv2, 81) + self.assertOneDeprecation(msg2.format(qual=qual, func=func)) + + with self.subTest(test=10): + rv1, rv2 = f('Pywiki', bar=10) + self.assertEqual(rv1, 'Pywiki') + self.assertEqual(rv2, 100) self.assertNoDeprecation() def test_remove_last_args_invalid(self) -> None: From cd2db153b1ed11545165b3c40f49ac392018bf15 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 5 Oct 2025 12:43:43 +0200 Subject: [PATCH 213/279] [IMPR] Add return after super(UI).encounter_color() call If none of stdin, stdout, or stderr is used, Win32UI.encounter_color() calls the base class implementation. This is not critical because the base class only raises NotImplementedError. However, returning after this call is safer, since the base implementation might change in the future and 'addr' would not be defined in such a case, leading to a NameError. Change-Id: I06007e07acfe2a64c564eaa553e4e897cbcf770d --- pywikibot/userinterfaces/terminal_interface_win32.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pywikibot/userinterfaces/terminal_interface_win32.py b/pywikibot/userinterfaces/terminal_interface_win32.py index 604fb6b6fd..1001eda38e 100644 --- a/pywikibot/userinterfaces/terminal_interface_win32.py +++ b/pywikibot/userinterfaces/terminal_interface_win32.py @@ -1,6 +1,6 @@ """User interface for Win32 terminals.""" # -# (C) Pywikibot team, 2003-2024 +# (C) Pywikibot team, 2003-2025 # # Distributed under the terms of the MIT license. # @@ -57,6 +57,7 @@ def encounter_color(self, color, addr = -12 else: super().encounter_color(color, target_stream) + return from ctypes.wintypes import DWORD, HANDLE get_handle = ctypes.WINFUNCTYPE(HANDLE, DWORD)( From 784db47dc6bc41acd17851c20370cafaa066acb8 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 5 Oct 2025 17:50:06 +0200 Subject: [PATCH 214/279] [cleanup] remove Python 2 code in aspects.py Change-Id: I788c55b803c1868041d1b3756ce102af652670a3 --- tests/aspects.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/aspects.py b/tests/aspects.py index e980ea90f8..4ddcc35918 100644 --- a/tests/aspects.py +++ b/tests/aspects.py @@ -1542,6 +1542,7 @@ class DeprecationTestCase(TestCase): r'(; use .* instead)?\.') source_adjustment_skips = [ + unittest.case._AssertRaisesBaseContext, unittest.case._AssertRaisesContext, TestCase.assertRaises, TestCase.assertRaisesRegex, @@ -1552,10 +1553,6 @@ class DeprecationTestCase(TestCase): # Require an instead string INSTEAD = object() - # Python 3 component in the call stack of _AssertRaisesContext - if hasattr(unittest.case, '_AssertRaisesBaseContext'): - source_adjustment_skips.append(unittest.case._AssertRaisesBaseContext) - def __init__(self, *args, **kwargs) -> None: """Initializer.""" super().__init__(*args, **kwargs) From 3807e2fa094d4502fda3cc5cdee2a846592532ff Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 5 Oct 2025 18:30:05 +0200 Subject: [PATCH 215/279] Tests: Remove unused return statements in tools_deprecate_tests Change-Id: I73f749403f7828368a4f0fcbc7770e209ed2d576 --- tests/tools_deprecate_tests.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/tools_deprecate_tests.py b/tests/tools_deprecate_tests.py index 5657ea3f07..776a51d393 100755 --- a/tests/tools_deprecate_tests.py +++ b/tests/tools_deprecate_tests.py @@ -76,7 +76,6 @@ def deprecated_func(foo=None): @deprecated() def deprecated_func_docstring(foo=None): """DEPRECATED. Deprecated function.""" - return foo @deprecated @@ -88,7 +87,6 @@ def deprecated_func2(foo=None): @deprecated def deprecated_func2_docstring(foo=None): """DEPRECATED, don't use this. Deprecated function.""" - return foo @deprecated(instead='baz') @@ -118,7 +116,6 @@ def deprecated_func_arg(foo=None): @deprecated def deprecated_func_docstring_arg(foo=None): """:param foo: Foo. DEPRECATED.""" - return foo @deprecated @@ -127,7 +124,6 @@ def deprecated_func_docstring_arg2(foo=None): :param foo: Foo. DEPRECATED. """ - return foo @deprecated_args(bah='foo') From 51fbabf855012265d91bbaa7066629023244624d Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 7 Oct 2025 08:34:20 +0200 Subject: [PATCH 216/279] IMPR: Show user-agent with version script Bug: T406458 Change-Id: If1ed512803e4609d67e36977ba61a7a524e90844 --- pywikibot/scripts/version.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/pywikibot/scripts/version.py b/pywikibot/scripts/version.py index 454f7f0261..661432bb6f 100755 --- a/pywikibot/scripts/version.py +++ b/pywikibot/scripts/version.py @@ -7,9 +7,24 @@ registered family .. versionchanged:: 7.0 - version script was moved to the framework scripts folder + version script was moved to the framework scripts folder. .. versionadded:: 9.1.2 - the *-nouser* option. + the *-nouser* option was added. +.. versionchanged:: 10.6 + The User-Agent string is now printed for the default site. To print + it for another site, call the ``pwb`` wrapper with the global option, + e.g.: + + pwb -site:wikipedia:test version + + .. note:: + The shown UA reflects the default config settings. It might differ + if a user-agent is passed via the *headers* parameter to + :func:`comms.http.request`, :func:`comms.http.fetch` or to + :class:`comms.eventstreams.EventStreams`. It can also differ if + :func:`comms.http.fetch` is used with *use_fake_user_agent* set to + ``True`` or to a custom UA string, or if + *fake_user_agent_exceptions* is defined in the :mod:`config` file. """ # # (C) Pywikibot team, 2007-2025 @@ -23,6 +38,7 @@ from pathlib import Path import pywikibot +from pywikibot.comms.http import user_agent from pywikibot.version import getversion @@ -93,6 +109,7 @@ def main(*args: str) -> None: pywikibot.info(' Please reinstall requests!') pywikibot.info('Python: ' + sys.version) + pywikibot.info('User-Agent: ' + user_agent(pywikibot.Site())) # check environment settings settings = {key for key in os.environ if key.startswith('PYWIKIBOT')} From 85b09a52bfaa3fe31b66108c6996c74f0ee2e3e7 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 3 Oct 2025 15:25:58 +0200 Subject: [PATCH 217/279] [bugfix] remove U+9676 replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The U+9676 replacement in transliteration._trans dict is the last one of a group of replacements made in compat release but isn't usefull for the same key. Finally there is a direct replacement made in transliterate method. Therefore remove it from dict and keep the later. Also - use umlauts for 'ö' and 'ü' like in 'ä' - remove replacement für 'C' (U+67) which is an ASCII char - split extended latin to IPA ans PUA - use positional-only argument for char - use keyword-only arguments for prev and succ parameters - some tests added Change-Id: I9448a2801d6110992d3f380f0ef6b9a501c3a515 --- pywikibot/userinterfaces/transliteration.py | 43 +++++++++++++-------- tests/ui_tests.py | 29 ++++++++++++-- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/pywikibot/userinterfaces/transliteration.py b/pywikibot/userinterfaces/transliteration.py index 7e297d26be..94179b46b8 100644 --- a/pywikibot/userinterfaces/transliteration.py +++ b/pywikibot/userinterfaces/transliteration.py @@ -6,7 +6,11 @@ # from __future__ import annotations -from pywikibot.tools import ModuleDeprecationWrapper, deprecate_arg +from pywikibot.tools import ( + ModuleDeprecationWrapper, + deprecate_arg, + deprecated_signature, +) #: Non ascii digits used by the framework @@ -70,11 +74,11 @@ 'Ṉ': 'N', 'Ṋ': 'N', 'Ɲ': 'N', 'ɲ': 'n', 'Ƞ': 'N', 'ǹ': 'n', 'ń': 'n', 'ñ': 'n', 'ņ': 'n', 'ň': 'n', 'ṅ': 'n', 'ṇ': 'n', 'ṉ': 'n', 'ṋ': 'n', 'ƞ': 'n', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ṍ': 'O', 'Ṏ': 'O', - 'Ȭ': 'O', 'Ö': 'O', 'Ō': 'O', 'Ṑ': 'O', 'Ṓ': 'O', 'Ŏ': 'O', 'Ǒ': 'O', + 'Ȭ': 'O', 'Ö': 'Oe', 'Ō': 'O', 'Ṑ': 'O', 'Ṓ': 'O', 'Ŏ': 'O', 'Ǒ': 'O', 'Ȯ': 'O', 'Ȱ': 'O', 'Ọ': 'O', 'Ǫ': 'O', 'Ǭ': 'O', 'Ơ': 'O', 'Ờ': 'O', 'Ớ': 'O', 'Ỡ': 'O', 'Ợ': 'O', 'Ở': 'O', 'Ỏ': 'O', 'Ɵ': 'O', 'Ø': 'O', 'Ǿ': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ṍ': 'o', 'ṏ': 'o', - 'ȭ': 'o', 'ö': 'o', 'ō': 'o', 'ṑ': 'o', 'ṓ': 'o', 'ŏ': 'o', 'ǒ': 'o', + 'ȭ': 'o', 'ö': 'oe', 'ō': 'o', 'ṑ': 'o', 'ṓ': 'o', 'ŏ': 'o', 'ǒ': 'o', 'ȯ': 'o', 'ȱ': 'o', 'ọ': 'o', 'ǫ': 'o', 'ǭ': 'o', 'ơ': 'o', 'ờ': 'o', 'ớ': 'o', 'ỡ': 'o', 'ợ': 'o', 'ở': 'o', 'ỏ': 'o', 'ɵ': 'o', 'ø': 'o', 'ǿ': 'o', 'Ȍ': 'Ö', 'Ő': 'Ö', 'Ȫ': 'Ö', 'ȍ': 'ö', 'ő': 'ö', 'ȫ': 'ö', @@ -90,10 +94,10 @@ 'Ṭ': 'T', 'Ṯ': 'T', 'Ṱ': 'T', 'Ŧ': 'T', 'Ƭ': 'T', 'Ʈ': 'T', 'ţ': 't', 'ț': 't', 'ť': 't', 'ṫ': 't', 'ṭ': 't', 'ṯ': 't', 'ṱ': 't', 'ŧ': 't', 'Ⱦ': 't', 'ƭ': 't', 'ʈ': 't', 'Ù': 'U', 'Ú': 'U', 'Ũ': 'U', 'Ṹ': 'U', - 'Ṵ': 'U', 'Ü': 'U', 'Ṳ': 'U', 'Ū': 'U', 'Ṻ': 'U', 'Ŭ': 'U', 'Ụ': 'U', + 'Ṵ': 'U', 'Ü': 'Ue', 'Ṳ': 'U', 'Ū': 'U', 'Ṻ': 'U', 'Ŭ': 'U', 'Ụ': 'U', 'Ů': 'U', 'Ų': 'U', 'Ǔ': 'U', 'Ṷ': 'U', 'Ủ': 'U', 'Ư': 'U', 'Ữ': 'U', 'Ự': 'U', 'Ử': 'U', 'ù': 'u', 'ú': 'u', 'ũ': 'u', 'ṹ': 'u', 'ṵ': 'u', - 'ü': 'u', 'ṳ': 'u', 'ū': 'u', 'ṻ': 'u', 'ŭ': 'u', 'ụ': 'u', 'ů': 'u', + 'ü': 'ue', 'ṳ': 'u', 'ū': 'u', 'ṻ': 'u', 'ŭ': 'u', 'ụ': 'u', 'ů': 'u', 'ų': 'u', 'ǔ': 'u', 'ṷ': 'u', 'ủ': 'u', 'ư': 'u', 'ữ': 'u', 'ự': 'u', 'ử': 'u', 'Ȕ': 'Ü', 'Ű': 'Ü', 'Ǜ': 'Ü', 'Ǘ': 'Ü', 'Ǖ': 'Ü', 'Ǚ': 'Ü', 'ȕ': 'ü', 'ű': 'ü', 'ǜ': 'ü', 'ǘ': 'ü', 'ǖ': 'ü', 'ǚ': 'ü', 'Û': 'Ux', @@ -113,12 +117,14 @@ 'Ƣ': 'G', 'ᵷ': 'g', 'ɣ': 'g', 'ƣ': 'g', 'ᵹ': 'g', 'Ƅ': 'H', 'ƅ': 'h', 'Ƕ': 'Wh', 'ƕ': 'wh', 'Ɩ': 'I', 'ɩ': 'i', 'Ŋ': 'Ng', 'ŋ': 'ng', 'Œ': 'OE', 'œ': 'oe', 'Ɔ': 'O', 'ɔ': 'o', 'Ȣ': 'Ou', 'ȣ': 'ou', 'Ƽ': 'Q', 'ĸ': 'q', - 'ƽ': 'q', 'ȹ': 'qp', '\uf20e': 'r', 'ſ': 's', 'ß': 'ss', 'Ʃ': 'Sh', - 'ʃ': 'sh', 'ᶋ': 'sh', 'Ʉ': 'U', 'ʉ': 'u', 'Ʌ': 'V', 'ʌ': 'v', 'Ɯ': 'W', - 'Ƿ': 'W', 'ɯ': 'w', 'ƿ': 'w', 'Ȝ': 'Y', 'ȝ': 'y', 'IJ': 'IJ', 'ij': 'ij', - 'Ƨ': 'Z', 'ʮ': 'z', 'ƨ': 'z', 'Ʒ': 'Zh', 'ʒ': 'zh', 'Ǯ': 'Dzh', 'ǯ': 'dzh', - 'Ƹ': "'", 'ƹ': "'", 'ʔ': "'", 'ˀ': "'", 'Ɂ': "'", 'ɂ': "'", 'Þ': 'Th', - 'þ': 'th', 'C': '!', 'ʗ': '!', 'ǃ': '!', + 'ƽ': 'q', 'ȹ': 'qp', 'ſ': 's', 'ß': 'ss', 'IJ': 'IJ', 'ij': 'ij', 'Ɯ': 'W', + 'Ƿ': 'W', 'ƿ': 'w', 'Ȝ': 'Y', 'ȝ': 'y', 'Ƨ': 'Z', 'ƨ': 'z', 'Ʒ': 'Zh', + 'ʒ': 'zh', 'Ǯ': 'Dzh', 'ǯ': 'dzh', 'Þ': 'Th', 'þ': 'th', + # International Phonetic Alphabet + 'ʃ': 'sh', 'ᶋ': 'sh', 'Ʉ': 'U', 'ʉ': 'u', 'Ʌ': 'V', 'ʌ': 'v', 'ʔ': "'", + 'ˀ': "'", 'Ɂ': "'", 'ɂ': "'", 'ʗ': '!', 'ǃ': '!', 'Ƹ': "'", 'ƹ': "'", + # Private Use Area + '': 'r', # Punctuation and typography '«': '"', '»': '"', '“': '"', '”': '"', '„': '"', '¨': '"', '‘': "'", '’': "'", '′': "'", '@': '(at)', '¤': '$', '¢': 'c', '€': 'E', '£': 'L', @@ -193,7 +199,6 @@ 'ى': 'á', 'ﻯ': 'á', 'ﻰ': 'á', 'ﯼ': 'y', 'ﯽ': 'y', 'ﯿ': 'y', 'ﯾ': 'y', 'ﻻ': 'la', 'ﻼ': 'la', 'ﷲ': 'llah', 'إ': "a'", 'أ': "a'", 'ؤ': "w'", 'ئ': "y'", - '◌': 'iy', # indicates absence of vowels # Perso-Arabic 'پ': 'p', 'ﭙ': 'p', 'چ': 'ch', 'ژ': 'zh', 'گ': 'g', 'ﮔ': 'g', 'ﮕ': 'g', 'ﮓ': 'g', @@ -1117,23 +1122,29 @@ def __init__(self, encoding: str) -> None: continue while (value.encode(encoding, 'replace').decode(encoding) == '?' and value in trans): - value = trans[value] + value = trans[value] # pragma: no cover trans[char] = value self.trans = trans @deprecate_arg('next', 'succ') # since 9.0 - def transliterate(self, char: str, default: str = '?', + @deprecated_signature(since='10.6.0') + def transliterate(self, char: str, /, default: str = '?', *, prev: str = '-', succ: str = '-') -> str: """Transliterate the character. .. versionchanged:: 9.0 *next* parameter was renamed to *succ*. + .. versionchanged:: 10.6 + *char* argument is positional only; *prev* and *succ* + arguments are keyword only. :param char: The character to transliterate. - :param default: The character used when there is no transliteration. + :param default: The character used when there is no + transliteration. :param prev: The previous character :param succ: The succeeding character - :return: The transliterated character which may be an empty string + :return: The transliterated character which may be an empty + string """ result = default if char in self.trans: diff --git a/tests/ui_tests.py b/tests/ui_tests.py index 74da260b77..fabe47149b 100755 --- a/tests/ui_tests.py +++ b/tests/ui_tests.py @@ -13,7 +13,9 @@ import platform import unittest from contextlib import nullcontext, redirect_stdout, suppress +from functools import partial from typing import NoReturn +from unicodedata import normalize from unittest.mock import patch import pywikibot @@ -33,7 +35,11 @@ terminal_interface_unix, terminal_interface_win32, ) -from pywikibot.userinterfaces.transliteration import NON_ASCII_DIGITS, _trans +from pywikibot.userinterfaces.transliteration import ( + NON_ASCII_DIGITS, + Transliterator, + _trans, +) from tests.aspects import TestCase, TestCaseBase @@ -366,21 +372,28 @@ def testOutputTransliteratedUnicodeText(self) -> None: '\x1b[93mu\x1b[0m\x1b[93me\x1b[0m\x1b[93mo\x1b[0m\n') -class TestTransliterationTable(TestCase): +class TestTransliteration(TestCase): """Test transliteration table.""" net = False + @classmethod + def setUpClass(cls) -> None: + """Set up Transliterator function.""" + trans = Transliterator('ascii') + cls.t = staticmethod(partial(trans.transliterate, prev='P')) + def test_ascii_digits(self) -> None: """Test that non ascii digits are in transliteration table.""" for lang, digits in NON_ASCII_DIGITS.items(): with self.subTest(lang=lang): - for char in digits: + for i, char in enumerate(digits): self.assertTrue(char.isdigit()) self.assertFalse(char.isascii()) self.assertIn(char, _trans, f'{char!r} not in transliteration table') + self.assertEqual(self.t(char), str(i)) def test_transliteration_table(self) -> None: """Test transliteration table consistency.""" @@ -388,6 +401,16 @@ def test_transliteration_table(self) -> None: with self.subTest(): self.assertNotEqual(k, v) + def test_transliterator(self) -> None: + """Test Transliterator.""" + for char in 'äöü': + self.assertEqual(self.t(char), normalize('NFD', char)[0] + 'e') + self.assertEqual(self.t('1'), '?') + self.assertEqual(self.t('◌'), 'P') + self.assertEqual(self.t('ッ'), '?') + self.assertEqual(self.t('仝'), 'P') + self.assertEqual(self.t('ຫ'), 'h') + # TODO: add tests for background colors. class FakeUITest(TestCase): From 14f540ff1bc7fe227578ea5acfabd3efd9fdbfd9 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 10 Oct 2025 17:07:08 +0200 Subject: [PATCH 218/279] Tests: Python 3.14 is published; use ist with tests Also update pre-commit hooks Change-Id: If6f0dadc8b0cf68e338516942dea2a56ccd453c0 --- .github/workflows/doctest.yml | 3 +-- .github/workflows/login_tests-ci.yml | 2 +- .github/workflows/oauth_tests-ci.yml | 2 +- .github/workflows/pre-commit.yml | 3 ++- .github/workflows/pywikibot-ci.yml | 8 +------- .github/workflows/windows_tests.yml | 2 +- .pre-commit-config.yaml | 6 +++--- 7 files changed, 10 insertions(+), 16 deletions(-) diff --git a/.github/workflows/doctest.yml b/.github/workflows/doctest.yml index eb55cb64a2..11bf199a65 100644 --- a/.github/workflows/doctest.yml +++ b/.github/workflows/doctest.yml @@ -22,10 +22,9 @@ jobs: fail-fast: false max-parallel: 17 matrix: - python-version: [pypy3.8, pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: [pypy3.8, pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] os: ['windows-latest', 'macOS-latest', 'ubuntu-latest'] include: - - python-version: 3.14-dev - python-version: 3.15-dev steps: - name: Checkout Repository diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index cd7023306a..b52bc6440c 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -42,7 +42,7 @@ jobs: fail-fast: false max-parallel: 1 matrix: - python-version: [pypy3.8, pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 3.14-dev, 3.15-dev] + python-version: [pypy3.8, pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 3.15-dev] site: ['wikipedia:en', 'wikisource:zh', 'wikipedia:test'] include: - python-version: '3.8' diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 0d8228ccae..32cd20f30c 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [pypy3.8, pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 3.14-dev, 3.15-dev] + python-version: [pypy3.8, pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 3.15-dev] family: [wikipedia] code: [test] domain: [test.wikipedia.org] diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 9ebe6c0bf3..7af8539090 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -24,11 +24,12 @@ jobs: python-version: - '3.9' - '3.13' + - '3.14' os: - windows-latest - macOS-latest include: - - python-version: 3.14-dev + - python-version: '3.14' - python-version: 3.15-dev steps: - name: set up python ${{ matrix.python-version }} diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index 54a8b7b6fe..d55a219160 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -25,7 +25,7 @@ jobs: fail-fast: false max-parallel: 19 matrix: - python-version: [pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: [pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] site: ['wikipedia:en', 'wikisource:zh'] include: - python-version: '3.8' @@ -59,12 +59,6 @@ jobs: - python-version: pypy3.8 site: wikisource:zh os: ubuntu-22.04 - - python-version: 3.14-dev - site: wikipedia:en - os: ubuntu-22.04 - - python-version: 3.14-dev - site: wikisource:zh - os: ubuntu-22.04 - python-version: 3.15-dev site: wikipedia:en os: ubuntu-22.04 diff --git a/.github/workflows/windows_tests.yml b/.github/workflows/windows_tests.yml index c78e045f9a..37ffa79c53 100644 --- a/.github/workflows/windows_tests.yml +++ b/.github/workflows/windows_tests.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8.0, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 3.14-dev] + python-version: [3.8.0, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] python-arch: [x64, x86] site: ['wikipedia:en'] steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e18061091..f934067bb1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,14 +65,14 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.2 + rev: v0.14.0 hooks: - id: ruff-check alias: ruff args: - --fix - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 + rev: v3.21.0 hooks: - id: pyupgrade args: @@ -89,7 +89,7 @@ repos: - --remove-unused-variables exclude: ^pywikibot/backports\.py$ - repo: https://github.com/PyCQA/isort - rev: 6.0.1 + rev: 6.1.0 hooks: - id: isort exclude: ^pwb\.py$ From 5d3c07bbcb03067691101070499022dbdd3c9818 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 10 Oct 2025 17:28:51 +0200 Subject: [PATCH 219/279] Tests: Use Python 3.14 for pre-commit tests with ubuntu Change-Id: Ie197dc5b76ff6552aebea0ee0d35c1e33f07feab --- .github/workflows/pre-commit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 7af8539090..85dae081ad 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -30,6 +30,7 @@ jobs: - macOS-latest include: - python-version: '3.14' + os: ubuntu-latest - python-version: 3.15-dev steps: - name: set up python ${{ matrix.python-version }} From 30fe415bf63ccc6d7c2edee53d5a45a27387ebac Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 11 Oct 2025 11:26:03 +0200 Subject: [PATCH 220/279] tests: try enabling interwiki_link_tests on github Bug: T403292 Change-Id: I942d17fe30f5b3ae65cf9f8079485289d99687f7 --- tests/interwiki_link_tests.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/interwiki_link_tests.py b/tests/interwiki_link_tests.py index f3e83bf079..6d805f7aef 100755 --- a/tests/interwiki_link_tests.py +++ b/tests/interwiki_link_tests.py @@ -7,7 +7,6 @@ # from __future__ import annotations -import os from contextlib import suppress from pywikibot import config @@ -46,8 +45,6 @@ def test_partially_qualified_NS1_family(self) -> None: self.assertEqual(link.namespace, 1) -@unittest.skipIf(os.environ.get('GITHUB_ACTIONS'), - 'Tests blocked on twn, see T403292') class TestInterwikiLinksToNonLocalSites(TestCase): """Tests for interwiki links to non local sites.""" From 4908ee2eef5f675462cfff4673be4d2e161c720e Mon Sep 17 00:00:00 2001 From: Sanjai Siddharthan Date: Sat, 11 Oct 2025 00:50:39 +0530 Subject: [PATCH 221/279] Add deprecation tag for Family.interwiki_replacement Bug: T399440 Change-Id: I27c4cc09b1d81d9220ba41fd6ffde58f79e240f9 --- ROADMAP.rst | 4 ++++ pywikibot/family.py | 5 ++++- tests/family_tests.py | 15 +++++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index 3e5439f884..62efaeb822 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -5,6 +5,8 @@ Current Release Changes be given as keyword arguments. * Return :meth:`bot.BaseBot.userPut` result with :meth:`AutomaticTWSummaryBot.put_current() ` method +* :meth:`Family.interwiki_replacements` is deprecated; + use :attr:`Family.code_aliases` instead. Deprecations @@ -13,6 +15,8 @@ Deprecations Pending removal in Pywikibot 13 ------------------------------- +* 10.6.0: :meth:`Family.interwiki_replacements` is deprecated; + use :attr:`Family.code_aliases` instead. * 10.6.0: Positional arguments of :func:`daemonize()` are deprecated and must be given as keyword arguments. * 10.5.0: Accessing the fallback '*' keys in 'languages', 'namespaces', 'namespacealiases', and diff --git a/pywikibot/family.py b/pywikibot/family.py index 9197646a04..73c43796a7 100644 --- a/pywikibot/family.py +++ b/pywikibot/family.py @@ -732,7 +732,7 @@ def obsolete(self) -> types.MappingProxyType[str, str | None]: :return: mapping of old codes to new codes (or None) """ data = dict.fromkeys(self.interwiki_removals) - data.update(self.interwiki_replacements) + data.update(self.code_aliases) return types.MappingProxyType(data) @classproperty @@ -749,6 +749,7 @@ def codes(cls) -> set[str]: return set(cls.langs.keys()) @classproperty + @deprecated('code_aliases', since='10.6.0') def interwiki_replacements(cls) -> Mapping[str, str]: """Return an interwiki code replacement mapping. @@ -757,6 +758,8 @@ def interwiki_replacements(cls) -> Mapping[str, str]: xx: now should get code yy:, add {'xx':'yy'} to :attr:`code_aliases`. + .. deprecated:: 10.6 + Use :attr:`code_aliases` directly instead. .. versionchanged:: 8.2 changed from dict to invariant mapping. """ diff --git a/tests/family_tests.py b/tests/family_tests.py index ca8d349b5f..b232698507 100755 --- a/tests/family_tests.py +++ b/tests/family_tests.py @@ -13,6 +13,7 @@ import pywikibot from pywikibot.exceptions import UnknownFamilyError from pywikibot.family import Family, SingleSiteFamily +from pywikibot.tools import suppress_warnings from tests.aspects import PatchingTestCase, TestCase, unittest from tests.utils import DrySite @@ -100,13 +101,16 @@ def test_get_obsolete_wp(self) -> None: self.assertIsInstance(family.obsolete, Mapping) # redirected code (see site tests test_alias_code_site) self.assertEqual(family.code_aliases['dk'], 'da') - self.assertEqual(family.interwiki_replacements['dk'], 'da') + msg = 'pywikibot.family.Family.interwiki_replacements is deprecated' + with suppress_warnings(msg, FutureWarning): + self.assertEqual(family.interwiki_replacements['dk'], 'da') self.assertEqual(family.obsolete['dk'], 'da') # closed/locked site (see site tests test_locked_site) self.assertIsNone(family.obsolete['mh']) # offline site (see site tests test_removed_site) self.assertIsNone(family.obsolete['ru-sib']) - self.assertIn('dk', family.interwiki_replacements) + with suppress_warnings(msg, FutureWarning): + self.assertIn('dk', family.interwiki_replacements) def test_obsolete_from_attributes(self) -> None: """Test obsolete property for given class attributes.""" @@ -114,14 +118,17 @@ def test_obsolete_from_attributes(self) -> None: family = type('TempFamily', (Family,), {})() self.assertEqual(family.obsolete, {}) - self.assertEqual(family.interwiki_replacements, {}) + msg = 'pywikibot.family.Family.interwiki_replacements is deprecated' + with suppress_warnings(msg, FutureWarning): + self.assertEqual(family.interwiki_replacements, {}) self.assertEqual(family.interwiki_removals, frozenset()) # Construct a temporary family with other attributes and instantiate it family = type('TempFamily', (Family,), {'code_aliases': {'a': 'b'}, 'closed_wikis': ['c']})() self.assertEqual(family.obsolete, {'a': 'b', 'c': None}) - self.assertEqual(family.interwiki_replacements, {'a': 'b'}) + with suppress_warnings(msg, FutureWarning): + self.assertEqual(family.interwiki_replacements, {'a': 'b'}) self.assertEqual(family.interwiki_removals, frozenset('c')) def test_obsolete_readonly(self) -> None: From 73f0ce6f974c1ca68b89e30452451d1d8f27e1b4 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 11 Oct 2025 14:32:22 +0200 Subject: [PATCH 222/279] Doc: Update ROADMAP.rst and AUTHORS.rst Change-Id: Ice50404436e8c0e313fbd7ee7438dbbaf71be5e0 --- AUTHORS.rst | 1 + ROADMAP.rst | 169 +++++++++++++++++++++++++-------------------- docs/changelog.rst | 2 +- 3 files changed, 96 insertions(+), 76 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index fa3bf8bff9..6fbbb27069 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -295,6 +295,7 @@ S :: + Sanjai Siddharthan Serio Santoro Scot Wilcoxon Shardul C diff --git a/ROADMAP.rst b/ROADMAP.rst index 62efaeb822..be4cfa4ff3 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,56 +1,70 @@ Current Release Changes ======================= +* :meth:`Family.interwiki_replacements` is deprecated; + use :attr:`Family.code_aliases` instead. +* The first parameter of :meth:`Transliterator.transliterate + ` is positional only + whereas *prev* and *succ* parameters are keyword only. The :class:`Transliterator + ` was improved. +* Show user-agent with :mod:`version` script (:phab:`T406458`) * Positional arguments of :func:`daemonize()` are deprecated and must be given as keyword arguments. -* Return :meth:`bot.BaseBot.userPut` result with :meth:`AutomaticTWSummaryBot.put_current() +* i18n updates. +* Return :meth:`bot.BaseBot.userPut` result from :meth:`AutomaticTWSummaryBot.put_current() ` method -* :meth:`Family.interwiki_replacements` is deprecated; - use :attr:`Family.code_aliases` instead. Deprecations ============ -Pending removal in Pywikibot 13 +This section lists features, methods, parameters, or attributes that are deprecated +and scheduled for removal in future Pywikibot releases. + +Deprecated items may still work in the current release but are no longer recommended for use. +Users should update their code according to the recommended alternatives. + +Pywikibot follows a clear deprecation policy: features are typically deprecated in one release and +removed in in the third subsequent major release, remaining available for the two releases in between. + + +Pending removal in Pywikibot 11 ------------------------------- -* 10.6.0: :meth:`Family.interwiki_replacements` is deprecated; - use :attr:`Family.code_aliases` instead. -* 10.6.0: Positional arguments of :func:`daemonize()` are deprecated and must - be given as keyword arguments. -* 10.5.0: Accessing the fallback '*' keys in 'languages', 'namespaces', 'namespacealiases', and - 'skins' properties of :attr:`APISite.siteinfo` are - deprecated and will be removed. -* 10.5.0: The methods :meth:`APISite.protection_types() - ` and :meth:`APISite.protection_levels() - ` are deprecated. - :attr:`APISite.restrictions` should be used instead. -* 10.4.0: Require all parameters of :meth:`Site.allpages() - ` except *start* to be keyword arguments. -* 10.4.0: Positional arguments of :class:`pywikibot.Coordinate` are deprecated and must be given as - keyword arguments. -* 10.3.0: :meth:`throttle.Throttle.getDelay` and :meth:`throttle.Throttle.setDelays` were renamed to - :meth:`get_delay()` and :meth:`set_delays() - `; the old methods will be removed (:phab:`T289318`) -* 10.3.0: :attr:`throttle.Throttle.next_multiplicity` attribute is unused and will be removed - (:phab:`T289318`) -* 10.3.0: *requestsize* parameter of :class:`throttle.Throttle` call is deprecated and will be - dropped (:phab:`T289318`) -* 10.3.0: :func:`textlib.to_latin_digits` will be removed in favour of - :func:`textlib.to_ascii_digits`, ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` - will be removed in favour of ``NON_ASCII_DIGITS`` (:phab:`T398146#10958283`) -* 10.2.0: :mod:`tools.threading.RLock` is deprecated and moved to :mod:`backports` - module. The :meth:`backports.RLock.count` method is also deprecated. For Python 3.14+ use ``RLock`` - from Python library ``threading`` instead. (:phab:`T395182`) -* 10.1.0: *revid* and *date* parameters of :meth:`Page.authorship() - ` were dropped -* 10.0.0: *last_id* of :class:`comms.eventstreams.EventStreams` was renamed to *last_event_id* - (:phab:`T309380`) -* 10.0.0: 'millenia' argument for *precision* parameter of :class:`pywikibot.WbTime` is deprecated; - 'millennium' must be used instead -* 10.0.0: *includeredirects* parameter of :func:`pagegenerators.AllpagesPageGenerator` and - :func:`pagegenerators.PrefixingPageGenerator` is deprecated and should be replaced by *filterredir* +* 8.4.0: :attr:`data.api.QueryGenerator.continuekey` will be removed in favour of + :attr:`data.api.QueryGenerator.modules` +* 8.4.0: The *modules_only_mode* parameter in the :class:`data.api.ParamInfo` class, its + *paraminfo_keys* class attribute, and its ``preloaded_modules`` property will be removed +* 8.4.0: The *dropdelay* and *releasepid* attributes of the :class:`throttle.Throttle` class will be + removed in favour of the *expiry* class attribute +* 8.2.0: The :func:`tools.itertools.itergroup` function will be removed in favour of the + :func:`backports.batched` function +* 8.2.0: The *normalize* parameter in the :meth:`pywikibot.WbTime.toTimestr` and + :meth:`pywikibot.WbTime.toWikibase` methods will be removed +* 8.1.0: The inheritance of the :exc:`exceptions.NoSiteLinkError` exception from + :exc:`exceptions.NoPageError` will be removed +* 8.1.0: The ``exceptions.Server414Error`` exception is deprecated in favour of the + :exc:`exceptions.Client414Error` exception +* 8.0.0: The :meth:`Timestamp.clone()` method is deprecated in + favour of the ``Timestamp.replace()`` method +* 8.0.0: The :meth:`family.Family.maximum_GET_length` method is deprecated in favour of the + :ref:`config.maximum_GET_length` configuration option (:phab:`T325957`) +* 8.0.0: The ``addOnly`` parameter in the :func:`textlib.replaceLanguageLinks` and + :func:`textlib.replaceCategoryLinks` functions is deprecated in favour of ``add_only`` +* 8.0.0: The regex attributes ``ptimeR``, ``ptimeznR``, ``pyearR``, ``pmonthR``, and ``pdayR`` of + the :class:`textlib.TimeStripper` class are deprecated in favour of the ``patterns`` attribute, + which is a :class:`textlib.TimeStripperPatterns` object +* 8.0.0: The ``groups`` attribute of the :class:`textlib.TimeStripper` class is deprecated in favour + of the :data:`textlib.TIMEGROUPS` constant +* 8.0.0: The :meth:`LoginManager.get_login_token` method + has been replaced by ``login.ClientLoginManager.site.tokens['login']`` +* 8.0.0: The ``data.api.LoginManager()`` constructor is deprecated in favour of the + :class:`login.ClientLoginManager` class +* 8.0.0: The :meth:`APISite.messages()` method is + deprecated in favour of the :attr:`userinfo['messages']` + attribute +* 8.0.0: The :meth:`Page.editTime()` method is deprecated and should be + replaced by the :attr:`Page.latest_revision.timestamp` attribute Pending removal in Pywikibot 12 @@ -90,40 +104,45 @@ Pending removal in Pywikibot 12 :attr:`tools.formatter.SequenceOutputter.out` property -Pending removal in Pywikibot 11 +Pending removal in Pywikibot 13 ------------------------------- -* 8.4.0: :attr:`data.api.QueryGenerator.continuekey` will be removed in favour of - :attr:`data.api.QueryGenerator.modules` -* 8.4.0: The *modules_only_mode* parameter in the :class:`data.api.ParamInfo` class, its - *paraminfo_keys* class attribute, and its ``preloaded_modules`` property will be removed -* 8.4.0: The *dropdelay* and *releasepid* attributes of the :class:`throttle.Throttle` class will be - removed in favour of the *expiry* class attribute -* 8.2.0: The :func:`tools.itertools.itergroup` function will be removed in favour of the - :func:`backports.batched` function -* 8.2.0: The *normalize* parameter in the :meth:`pywikibot.WbTime.toTimestr` and - :meth:`pywikibot.WbTime.toWikibase` methods will be removed -* 8.1.0: The inheritance of the :exc:`exceptions.NoSiteLinkError` exception from - :exc:`exceptions.NoPageError` will be removed -* 8.1.0: The ``exceptions.Server414Error`` exception is deprecated in favour of the - :exc:`exceptions.Client414Error` exception -* 8.0.0: The :meth:`Timestamp.clone()` method is deprecated in - favour of the ``Timestamp.replace()`` method -* 8.0.0: The :meth:`family.Family.maximum_GET_length` method is deprecated in favour of the - :ref:`config.maximum_GET_length` configuration option (:phab:`T325957`) -* 8.0.0: The ``addOnly`` parameter in the :func:`textlib.replaceLanguageLinks` and - :func:`textlib.replaceCategoryLinks` functions is deprecated in favour of ``add_only`` -* 8.0.0: The regex attributes ``ptimeR``, ``ptimeznR``, ``pyearR``, ``pmonthR``, and ``pdayR`` of - the :class:`textlib.TimeStripper` class are deprecated in favour of the ``patterns`` attribute, - which is a :class:`textlib.TimeStripperPatterns` object -* 8.0.0: The ``groups`` attribute of the :class:`textlib.TimeStripper` class is deprecated in favour - of the :data:`textlib.TIMEGROUPS` constant -* 8.0.0: The :meth:`LoginManager.get_login_token` method - has been replaced by ``login.ClientLoginManager.site.tokens['login']`` -* 8.0.0: The ``data.api.LoginManager()`` constructor is deprecated in favour of the - :class:`login.ClientLoginManager` class -* 8.0.0: The :meth:`APISite.messages()` method is - deprecated in favour of the :attr:`userinfo['messages']` - attribute -* 8.0.0: The :meth:`Page.editTime()` method is deprecated and should be - replaced by the :attr:`Page.latest_revision.timestamp` attribute +* 10.6.0: :meth:`Family.interwiki_replacements` is deprecated; + use :attr:`Family.code_aliases` instead. +* Keyword argument for *char* parameter of :meth:`Transliterator.transliterate + ` and + positional arguments for *prev* and *succ* parameters are deprecated. +* 10.6.0: Positional arguments of :func:`daemonize()` are deprecated and must + be given as keyword arguments. +* 10.5.0: Accessing the fallback '*' keys in 'languages', 'namespaces', 'namespacealiases', and + 'skins' properties of :attr:`APISite.siteinfo` are + deprecated and will be removed. +* 10.5.0: The methods :meth:`APISite.protection_types() + ` and :meth:`APISite.protection_levels() + ` are deprecated. + :attr:`APISite.restrictions` should be used instead. +* 10.4.0: Require all parameters of :meth:`Site.allpages() + ` except *start* to be keyword arguments. +* 10.4.0: Positional arguments of :class:`pywikibot.Coordinate` are deprecated and must be given as + keyword arguments. +* 10.3.0: :meth:`throttle.Throttle.getDelay` and :meth:`throttle.Throttle.setDelays` were renamed to + :meth:`get_delay()` and :meth:`set_delays() + `; the old methods will be removed (:phab:`T289318`) +* 10.3.0: :attr:`throttle.Throttle.next_multiplicity` attribute is unused and will be removed + (:phab:`T289318`) +* 10.3.0: *requestsize* parameter of :class:`throttle.Throttle` call is deprecated and will be + dropped (:phab:`T289318`) +* 10.3.0: :func:`textlib.to_latin_digits` will be removed in favour of + :func:`textlib.to_ascii_digits`, ``NON_LATIN_DIGITS`` of :mod:`userinterfaces.transliteration` + will be removed in favour of ``NON_ASCII_DIGITS`` (:phab:`T398146#10958283`) +* 10.2.0: :mod:`tools.threading.RLock` is deprecated and moved to :mod:`backports` + module. The :meth:`backports.RLock.count` method is also deprecated. For Python 3.14+ use ``RLock`` + from Python library ``threading`` instead. (:phab:`T395182`) +* 10.1.0: *revid* and *date* parameters of :meth:`Page.authorship() + ` were dropped +* 10.0.0: *last_id* of :class:`comms.eventstreams.EventStreams` was renamed to *last_event_id* + (:phab:`T309380`) +* 10.0.0: 'millenia' argument for *precision* parameter of :class:`pywikibot.WbTime` is deprecated; + 'millennium' must be used instead +* 10.0.0: *includeredirects* parameter of :func:`pagegenerators.AllpagesPageGenerator` and + :func:`pagegenerators.PrefixingPageGenerator` is deprecated and should be replaced by *filterredir* diff --git a/docs/changelog.rst b/docs/changelog.rst index de64526e5a..5a6a5bacb1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,7 +2,7 @@ Change log ********** -What is new with Pywikibot |release|? What are the main changes of older version? +New features, improvements, and fixes in Pywikibot |release|. .. include:: ../ROADMAP.rst From a9dc1c4d7751f60d39cf8cc8f34174e339eecd6f Mon Sep 17 00:00:00 2001 From: tejashxv Date: Sun, 14 Sep 2025 23:46:31 +0530 Subject: [PATCH 223/279] Upgrade assertRaises to assertRaisesRegex Converts 25 assertRaises calls to assertRaisesRegex to validate specific error messages, not just types. Bug: T154281 Change-Id: Ic40cd95aad746d770e310043384ca9ce5d87b79e --- tests/api_tests.py | 32 +++++++++++++------ tests/archivebot_tests.py | 25 ++++++++++----- tests/file_tests.py | 57 ++++++++++++++++++++++------------ tests/page_tests.py | 6 +++- tests/proofreadpage_tests.py | 17 +++++----- tests/site_generators_tests.py | 2 +- tests/wbtypes_tests.py | 15 ++++++--- 7 files changed, 102 insertions(+), 52 deletions(-) diff --git a/tests/api_tests.py b/tests/api_tests.py index 81f54bf379..c1bbaba912 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -294,9 +294,10 @@ class TestOptionSet(TestCase): def test_non_lazy_load(self) -> None: """Test OptionSet with initialised site.""" options = api.OptionSet(self.get_site(), 'recentchanges', 'show') - with self.assertRaises(KeyError): + with self.assertRaisesRegex(KeyError, 'Invalid name "invalid_name"'): options.__setitem__('invalid_name', True) - with self.assertRaises(ValueError): + with self.assertRaisesRegex( + ValueError, 'Invalid value "invalid_value"'): options.__setitem__('anon', 'invalid_value') options['anon'] = True self.assertCountEqual(['anon'], options._enabled) @@ -324,13 +325,17 @@ def test_lazy_load(self) -> None: options['anon'] = True self.assertIn('invalid_name', options._enabled) self.assertLength(options, 2) - with self.assertRaises(KeyError): + with self.assertRaisesRegex( + KeyError, + r'OptionSet already contains invalid name\(s\) "invalid_name"' + ): options._set_site(self.get_site(), 'recentchanges', 'show') self.assertLength(options, 2) options._set_site(self.get_site(), 'recentchanges', 'show', clear_invalid=True) self.assertLength(options, 1) - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, 'The site cannot be set multiple times.'): options._set_site(self.get_site(), 'recentchanges', 'show') @@ -529,7 +534,8 @@ def test_many_continuations_limited(self) -> None: gen = api.PropertyGenerator( site=self.site, prop='revisions|info|categoryinfo|langlinks|templates', - parameters=params) + parameters=params + ) # An APIError is raised if set_maximum_items is not called. gen.set_maximum_items(-1) # suppress use of "rvlimit" parameter @@ -629,13 +635,18 @@ def test_namespace_param_is_not_settable(self) -> None: def test_namespace_none(self) -> None: """Test ListGenerator set_namespace with None.""" self.gen = api.ListGenerator(listaction='alllinks', site=self.site) - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + (r'int\(\) argument must be a string, a bytes-like object ' + r"or (?:a real number|a number), not 'NoneType'")): self.gen.set_namespace(None) def test_namespace_non_multi(self) -> None: """Test ListGenerator set_namespace when non multi.""" self.gen = api.ListGenerator(listaction='alllinks', site=self.site) - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + 'alllinks module does not support multiple namespaces'): self.gen.set_namespace([0, 1]) self.assertIsNone(self.gen.set_namespace(0)) @@ -649,7 +660,7 @@ def test_namespace_resolve_failed(self) -> None: """Test ListGenerator set_namespace when resolve fails.""" self.gen = api.ListGenerator(listaction='allpages', site=self.site) self.assertTrue(self.gen.support_namespace()) - with self.assertRaises(KeyError): + with self.assertRaisesRegex(KeyError, '10000'): self.gen.set_namespace(10000) @@ -675,7 +686,10 @@ def setUp(self) -> None: def test_namespace_none(self) -> None: """Test ListGenerator set_namespace with None.""" - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + (r'int\(\) argument must be a string, a bytes-like object ' + r"or (?:a real number|a number), not 'NoneType'")): self.gen.set_namespace(None) def test_namespace_zero(self) -> None: diff --git a/tests/archivebot_tests.py b/tests/archivebot_tests.py index 5ea6734083..7b78a3d03e 100755 --- a/tests/archivebot_tests.py +++ b/tests/archivebot_tests.py @@ -94,15 +94,22 @@ def test_str2size(self) -> None: def test_str2size_failures(self) -> None: """Test for rejecting of invalid shorthand notation of sizes.""" - with self.assertRaises(archivebot.MalformedConfigError): + with self.assertRaisesRegex( + archivebot.MalformedConfigError, "Couldn't parse size: 4 KK"): archivebot.str2size('4 KK') - with self.assertRaises(archivebot.MalformedConfigError): + with self.assertRaisesRegex( + archivebot.MalformedConfigError, "Couldn't parse size: K4"): archivebot.str2size('K4') - with self.assertRaises(archivebot.MalformedConfigError): + with self.assertRaisesRegex( + archivebot.MalformedConfigError, "Couldn't parse size: 4X"): archivebot.str2size('4X') - with self.assertRaises(archivebot.MalformedConfigError): + with self.assertRaisesRegex( + archivebot.MalformedConfigError, + "Couldn't parse size: 1 234 56"): archivebot.str2size('1 234 56') - with self.assertRaises(archivebot.MalformedConfigError): + with self.assertRaisesRegex( + archivebot.MalformedConfigError, + "Couldn't parse size: 1234 567"): archivebot.str2size('1234 567') @@ -130,8 +137,8 @@ def test_archivebot(self, code=None) -> None: self.assertIsInstance(talk.threads, list) self.assertGreaterEqual( len(talk.threads), THREADS[code], - f'{len(talk.threads)} Threads found on {talk},\n{THREADS[code]} or' - ' more expected' + f'{len(talk.threads)} Threads found on {talk},\n' + f'{THREADS[code]} or more expected' ) for thread in talk.threads: @@ -344,7 +351,9 @@ def testLoadConfigInOtherNamespace(self) -> None: except Error as e: # pragma: no cover self.fail(f'PageArchiver() raised {e}!') - with self.assertRaises(archivebot.MissingConfigError): + with self.assertRaisesRegex( + archivebot.MissingConfigError, + 'Missing or malformed template'): archivebot.PageArchiver(page, tmpl_without_ns, '') diff --git a/tests/file_tests.py b/tests/file_tests.py index 4d6e611e91..1d8ec7e5b0 100755 --- a/tests/file_tests.py +++ b/tests/file_tests.py @@ -79,8 +79,10 @@ def test_shared_only(self) -> None: def test_local_only(self) -> None: """Test file_is_shared() on file page with local file only.""" - title = 'File:Untitled (Three Forms), stainless steel sculpture by ' \ - '--James Rosati--, 1975-1976, --Honolulu Academy of Arts--.JPG' + title = ( + 'File:Untitled (Three Forms), stainless steel sculpture by ' + '--James Rosati--, 1975-1976, --Honolulu Academy of Arts--.JPG' + ) commons = self.get_site('commons') enwp = self.get_site('enwiki') @@ -238,12 +240,14 @@ def test_lazyload_metadata(self) -> None: def test_get_file_url(self) -> None: """Get File url.""" self.assertTrue(self.image.exists()) - self.assertEqual(self.image.get_file_url(), - 'https://upload.wikimedia.org/wikipedia/commons/' - 'd/d3/Albert_Einstein_Head.jpg') - self.assertEqual(self.image.latest_file_info.url, - 'https://upload.wikimedia.org/wikipedia/commons/' - 'd/d3/Albert_Einstein_Head.jpg') + self.assertEqual( + self.image.get_file_url(), + 'https://upload.wikimedia.org/wikipedia/commons/' + 'd/d3/Albert_Einstein_Head.jpg') + self.assertEqual( + self.image.latest_file_info.url, + 'https://upload.wikimedia.org/wikipedia/commons/' + 'd/d3/Albert_Einstein_Head.jpg') @unittest.expectedFailure # T391761 def test_get_file_url_thumburl_from_width(self) -> None: @@ -333,8 +337,8 @@ def test_changed_title(self) -> None: def test_not_existing_download(self) -> None: """Test not existing download.""" - page = pywikibot.FilePage(self.site, - 'File:notexisting_Albert Einstein.jpg') + page = pywikibot.FilePage( + self.site, 'File:notexisting_Albert Einstein.jpg') filename = join_images_path('Albert Einstein.jpg') with self.assertRaisesRegex( @@ -386,25 +390,33 @@ def test_data_item(self) -> None: def test_data_item_not_file(self) -> None: """Test data item with invalid pageid.""" item = pywikibot.MediaInfo(self.site, 'M1') # Main Page - with self.assertRaises(Error): + with self.assertRaisesRegex(Error, r'not.*file'): item.file - with self.assertRaises(NoWikibaseEntityError): + with self.assertRaisesRegex( + NoWikibaseEntityError, + r"Entity.*(not.*exist|doesn't exist)"): item.get() self.assertFalse(item.exists()) def test_data_item_when_no_file_or_data_item(self) -> None: """Test data item associated to file that does not exist.""" - page = pywikibot.FilePage(self.site, - 'File:Notexisting_Albert Einstein.jpg') + page = pywikibot.FilePage( + self.site, 'File:Notexisting_Albert Einstein.jpg') self.assertFalse(page.exists()) item = page.data_item() self.assertIsInstance(item, pywikibot.MediaInfo) - with self.assertRaises(NoWikibaseEntityError): + with self.assertRaisesRegex( + NoWikibaseEntityError, + r"Entity.*(not.*exist|doesn't exist)"): item.get() - with self.assertRaises(NoWikibaseEntityError): + with self.assertRaisesRegex( + NoWikibaseEntityError, + r"Entity.*(not.*exist|doesn't exist)"): item.title() - with self.assertRaises(NoWikibaseEntityError): + with self.assertRaisesRegex( + NoWikibaseEntityError, + r"Entity.*(not.*exist|doesn't exist)"): item.labels def test_data_item_when_file_exist_but_without_item(self) -> None: @@ -520,11 +532,15 @@ def test_edit_claims(self) -> None: item = page.data_item() # Insert claim to non-existing file - with self.assertRaises(NoWikibaseEntityError): + with self.assertRaisesRegex( + NoWikibaseEntityError, + r"Entity.*(not.*exist|doesn't exist)"): item.addClaim(new_claim) # Insert claim using site object to non-existing file - with self.assertRaises(NoWikibaseEntityError): + with self.assertRaisesRegex( + NoWikibaseEntityError, + r"Entity.*(not.*exist|doesn't exist)"): self.site.addClaim(item, new_claim) # Test adding claim existing file @@ -574,7 +590,8 @@ def test_edit_claims(self) -> None: self.assertTrue(claim_found) # Note removeClaims() parameter needs to be array - summary = f'Removing {property_id} with {value} using site object' + summary = (f'Removing {property_id} with {value} ' + 'using site object') self.site.removeClaims(remove_statements, summary=summary) # Test that the claims were actually removed diff --git a/tests/page_tests.py b/tests/page_tests.py index 688e02ea1f..cf7e866531 100755 --- a/tests/page_tests.py +++ b/tests/page_tests.py @@ -273,7 +273,11 @@ def testFileTitle(self) -> None: 'File:Example #3.jpg', # file extension in section ): with self.subTest(title=title), \ - self.assertRaises(ValueError): + self.assertRaisesRegex( + ValueError, + r'(not.*valid.*file' + r'|not in the file namespace' + r'|does not have a valid extension)'): pywikibot.FilePage(site, title) def testImageAndDataRepository(self) -> None: diff --git a/tests/proofreadpage_tests.py b/tests/proofreadpage_tests.py index 1e6d481db1..94b20ecb18 100755 --- a/tests/proofreadpage_tests.py +++ b/tests/proofreadpage_tests.py @@ -75,8 +75,10 @@ def test_tag_attr_str(self) -> None: def test_tag_attr_exceptions(self) -> None: """Test TagAttr for Exceptions.""" - self.assertRaises(ValueError, TagAttr, 'fromsection', 'A123"') - self.assertRaises(TypeError, TagAttr, 'fromsection', 3.0) + with self.assertRaisesRegex(ValueError, 'has wrong quotes'): + TagAttr('fromsection', 'A123"') + with self.assertRaisesRegex(TypeError, 'must be str or int'): + TagAttr('fromsection', 3.0) def test_pages_tag_parser(self) -> None: """Test PagesTagParser.""" @@ -110,14 +112,13 @@ def test_pages_tag_parser(self) -> None: def test_pages_tag_parser_exceptions(self) -> None: """Test PagesTagParser Exceptions.""" - text = """Text: """ + parser = PagesTagParser(text) + self.assertEqual(parser.index, 'Index.pdf') text = """Text: """ - self.assertRaises(ValueError, PagesTagParser, text) + with self.assertRaisesRegex(ValueError, 'has wrong quotes'): + PagesTagParser(text) class TestProofreadPageInvalidSite(TestCase): diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index 063bb0c50f..9fc4172a06 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -1819,7 +1819,7 @@ def setUp(self) -> None: def test_backlinks_redirects_length(self) -> None: """Test backlinks redirects length.""" self.assertLength(self.backlinks, 1) - self.assertLength(self.references, 1) + self.assertLength(set(self.references), 1) self.assertLength(self.nofollow, 1) def test_backlinks_redirects_status(self) -> None: diff --git a/tests/wbtypes_tests.py b/tests/wbtypes_tests.py index c9ac6da42d..e1e0a47f7d 100755 --- a/tests/wbtypes_tests.py +++ b/tests/wbtypes_tests.py @@ -478,7 +478,8 @@ def test_comparison(self) -> None: 'Invalid precision: "invalid_precision"'): pywikibot.WbTime(0, site=repo, precision='invalid_precision') self.assertIsInstance(t1.toTimestamp(), pywikibot.Timestamp) - self.assertRaises(ValueError, t2.toTimestamp) + with self.assertRaisesRegex(ValueError, 'BC dates.*Timestamp'): + t2.toTimestamp() def test_comparison_types(self) -> None: """Test WbTime comparison with different types.""" @@ -486,10 +487,14 @@ def test_comparison_types(self) -> None: t1 = pywikibot.WbTime(site=repo, year=2010, hour=12, minute=43) t2 = pywikibot.WbTime(site=repo, year=-2005, hour=16, minute=45) self.assertGreater(t1, t2) - self.assertRaises(TypeError, operator.lt, t1, 5) - self.assertRaises(TypeError, operator.gt, t1, 5) - self.assertRaises(TypeError, operator.le, t1, 5) - self.assertRaises(TypeError, operator.ge, t1, 5) + with self.assertRaisesRegex(TypeError, 'not supported'): + operator.lt(t1, 5) + with self.assertRaisesRegex(TypeError, 'not supported'): + operator.gt(t1, 5) + with self.assertRaisesRegex(TypeError, 'not supported'): + operator.le(t1, 5) + with self.assertRaisesRegex(TypeError, 'not supported'): + operator.ge(t1, 5) def test_comparison_timezones(self) -> None: """Test comparisons with timezones.""" From e619c210856d057d8b419f999ab396f2592456f2 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 11 Oct 2025 10:26:44 +0200 Subject: [PATCH 224/279] cleanup: No longer inherit SupersetQuery from WaitingMixin There is no wait cycle used with SupersetQuery; therefore WaitingMixin is superfluous. Change-Id: Iab6abd37adb95c40d0755f21460b9f48b7c1ac6f --- pywikibot/data/superset.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pywikibot/data/superset.py b/pywikibot/data/superset.py index 7b4c969fc6..9f49509790 100644 --- a/pywikibot/data/superset.py +++ b/pywikibot/data/superset.py @@ -3,7 +3,7 @@ .. versionadded:: 9.2 """ # -# (C) Pywikibot team, 2024 +# (C) Pywikibot team, 2024-2025 # # Distributed under the terms of the MIT license. # @@ -15,7 +15,6 @@ import pywikibot from pywikibot.comms import http -from pywikibot.data import WaitingMixin from pywikibot.exceptions import NoUsernameError, ServerError @@ -23,7 +22,7 @@ from pywikibot.site import BaseSite -class SupersetQuery(WaitingMixin): +class SupersetQuery: """Superset Query class. From 71fe1f4ddae06fc0fd729b7ea70b34fc1b44ae09 Mon Sep 17 00:00:00 2001 From: Strainu Date: Fri, 10 Oct 2025 23:41:03 +0300 Subject: [PATCH 225/279] [Family] Add Romanian templates in Wikipedia family Change-Id: Id532a00d20129d02f386331c526261226329941a Signed-off-by: Strainu --- pywikibot/families/wikipedia_family.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pywikibot/families/wikipedia_family.py b/pywikibot/families/wikipedia_family.py index 6a546f85ce..cb80610cce 100644 --- a/pywikibot/families/wikipedia_family.py +++ b/pywikibot/families/wikipedia_family.py @@ -203,6 +203,7 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'he': ('בעבודה',), 'hr': ('Radovi',), 'hy': ('Խմբագրում եմ',), + 'ro': ('Dezvoltare', 'S-dezvoltare', 'Modific acum'), 'ru': ('Редактирую',), 'sr': ('Радови у току', 'Рут'), 'test': ('In use',), @@ -219,6 +220,7 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'Archivace start', 'Posloupnost archivů', 'Rfa-archiv-start', 'Rfc-archiv-start'), 'de': ('Archiv',), + 'ro': ('Arhivă',), } @classmethod From d4128af9165075a0b120e5ffc0c511f8c7dcde78 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 11 Oct 2025 18:39:01 +0200 Subject: [PATCH 226/279] tests: test supertest with Pywikibot-oauth account Bug: T395664 Change-Id: Iff3af0fd2186a3161a31f1289fcc31f5af17cc8a --- .github/workflows/oauth_tests-ci.yml | 2 +- tests/superset_tests.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 32cd20f30c..ca21d53e27 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -96,7 +96,7 @@ jobs: env: PYWIKIBOT_TEST_WRITE: 1 PYWIKIBOT_TEST_OAUTH: ${{ secrets[format('{0}', steps.token.outputs.uppercase)] }} - PYWIKIBOT_TEST_MODULES: edit_failure,file,oauth + PYWIKIBOT_TEST_MODULES: edit_failure,file,oauth,superset run: | python pwb.py version coverage run -m unittest -vv diff --git a/tests/superset_tests.py b/tests/superset_tests.py index 7977f2fdab..32adeee67a 100755 --- a/tests/superset_tests.py +++ b/tests/superset_tests.py @@ -52,7 +52,6 @@ class TestSupersetWithAuth(TestCase): family = 'meta' code = 'meta' - @unittest.expectedFailure # T395664 def test_login_and_oauth_permission(self) -> None: """Superset login and queries.""" sql = 'SELECT page_id, page_title FROM page LIMIT 2;' From aa8b92fd21fd939c8c492a56c2fb9c29e806f191 Mon Sep 17 00:00:00 2001 From: Strainu Date: Fri, 10 Oct 2025 14:55:32 +0300 Subject: [PATCH 227/279] Add Citoid API to pywikibot Bug: T401690 Change-Id: I272a838d604967d8b84c70a03c92e1f4aaa91029 Signed-off-by: Strainu --- pywikibot/data/citoid.py | 59 +++++++++++++++++++++++++ pywikibot/exceptions.py | 5 +++ pywikibot/families/wikipedia_family.py | 2 + tests/citoid_tests.py | 61 ++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 pywikibot/data/citoid.py create mode 100755 tests/citoid_tests.py diff --git a/pywikibot/data/citoid.py b/pywikibot/data/citoid.py new file mode 100644 index 0000000000..ee4a9303db --- /dev/null +++ b/pywikibot/data/citoid.py @@ -0,0 +1,59 @@ +"""Superset Query interface. + +.. versionadded:: 10.6 +""" +# +# (C) Pywikibot team, 2025 +# +# Distributed under the terms of the MIT license. +# +from __future__ import annotations + +import urllib.parse + +import pywikibot +from pywikibot.comms import http +from pywikibot.exceptions import ApiNotAvailableError, Error +from pywikibot.site import BaseSite + + +VALID_FORMAT = [ + 'mediawiki', 'wikibase', 'zotero', 'bibtex', 'mediawiki-basefields' +] + + +class CitoidClient: + + """Citoid client class. + + This class allows to call the Citoid API used in production. + """ + + def __init__(self, site: BaseSite): + """Initialize the CitoidClient.""" + self.site = site + + def get_citation(self, response_format: str, ref_url: str) -> dict: + """Get a citation from the citoid service. + + :param response_format: Return format, e.g. 'bibtex', 'wikibase', etc. + :param ref_url: The URL to get the citation for. + :return: A dictionary with the citation data. + """ + if response_format not in VALID_FORMAT: + raise ValueError(f'Invalid format {response_format}, ' + f'must be one of {VALID_FORMAT}') + if (not hasattr(self.site.family, 'citoid_endpoint') + or not self.site.family.citoid_endpoint): + raise ApiNotAvailableError( + f'Citoid endpoint not configured for {self.site.family.name}') + base_url = self.site.family.citoid_endpoint + ref_url = urllib.parse.quote(ref_url, safe='') + api_url = urllib.parse.urljoin(base_url, + f'{response_format}/{ref_url}') + try: + json = http.request(self.site, api_url).json() + return json + except Error as e: + pywikibot.log(f'Caught pywikibot error {e}') + raise diff --git a/pywikibot/exceptions.py b/pywikibot/exceptions.py index 00f1a269d2..d1a095ad83 100644 --- a/pywikibot/exceptions.py +++ b/pywikibot/exceptions.py @@ -728,6 +728,11 @@ class MaxlagTimeoutError(TimeoutError): """Request failed with a maxlag timeout error.""" +class ApiNotAvailableError(Error): + + """API is not available, e.g. due to a network error or configuration.""" + + wrapper = ModuleDeprecationWrapper(__name__) wrapper.add_deprecated_attr( 'Server414Error', Client414Error, since='8.1.0') diff --git a/pywikibot/families/wikipedia_family.py b/pywikibot/families/wikipedia_family.py index cb80610cce..8f960fea4f 100644 --- a/pywikibot/families/wikipedia_family.py +++ b/pywikibot/families/wikipedia_family.py @@ -223,6 +223,8 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'ro': ('Arhivă',), } + citoid_endpoint = '/api/rest_v1/data/citation/' + @classmethod def __post_init__(cls) -> None: """Add 'yue' code alias due to :phab:`T341960`. diff --git a/tests/citoid_tests.py b/tests/citoid_tests.py new file mode 100755 index 0000000000..b4a6c96146 --- /dev/null +++ b/tests/citoid_tests.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Unit tests for citoid script.""" +# +# (C) Pywikibot team, 2025 +# +# Distributed under the terms of the MIT license. +# +from __future__ import annotations + +import datetime + +import pywikibot +from pywikibot.data import citoid +from pywikibot.exceptions import ApiNotAvailableError +from tests.aspects import TestCase + + +class TestCitoid(TestCase): + + """Test the Citoid client.""" + + family = 'wikipedia' + code = 'test' + login = False + + def test_citoid_positive(self): + """Test citoid script.""" + client = citoid.CitoidClient(self.site) + resp = client.get_citation( + 'mediawiki', + 'https://ro.wikipedia.org/wiki/România' + ) + self.assertLength(resp, 1) + self.assertEqual(resp[0]['title'], 'România') + self.assertEqual( + resp[0]['rights'], + 'Creative Commons Attribution-ShareAlike License' + ) + self.assertIsNotEmpty(resp[0]['url']) + self.assertEqual( + resp[0]['accessDate'], + datetime.datetime.now().strftime('%Y-%m-%d') + ) + + def test_citoid_no_config(self): + """Test citoid script with no citoid endpoint configured.""" + client = citoid.CitoidClient(pywikibot.Site('pl', 'wikiquote')) + with self.assertRaises(ApiNotAvailableError): + client.get_citation( + 'mediawiki', + 'https://ro.wikipedia.org/wiki/România' + ) + + def test_citoid_no_valid_format(self): + """Test citoid script with invalid format provided.""" + client = citoid.CitoidClient(self.site) + with self.assertRaises(ValueError): + client.get_citation( + 'mediawiki2', + 'https://ro.wikipedia.org/wiki/România' + ) From 5b48256dddffba019c888034cb44cd2f7d1a9d72 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 12 Oct 2025 10:28:35 +0200 Subject: [PATCH 228/279] Announcement:Show a warning if Pywikibot is running with Python 3.8 Bug: T401802 Change-Id: I0edb2ded34360fa2739eb0c93bae393e1e2d6719 --- ROADMAP.rst | 2 ++ docs/index.rst | 3 ++- pywikibot/__init__.py | 11 ++++++++++- tests/utils.py | 4 ++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index be4cfa4ff3..e1a8b9e6d7 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,6 +1,7 @@ Current Release Changes ======================= +* Python 3.8 support will be discontinued and probably this is the last version supporting it. * :meth:`Family.interwiki_replacements` is deprecated; use :attr:`Family.code_aliases` instead. * The first parameter of :meth:`Transliterator.transliterate @@ -31,6 +32,7 @@ removed in in the third subsequent major release, remaining available for the tw Pending removal in Pywikibot 11 ------------------------------- +* 10.6.0: Python 3.8 support is deprecated and will be dropped soon * 8.4.0: :attr:`data.api.QueryGenerator.continuekey` will be removed in favour of :attr:`data.api.QueryGenerator.modules` * 8.4.0: The *modules_only_mode* parameter in the :class:`data.api.ParamInfo` class, its diff --git a/docs/index.rst b/docs/index.rst index 46b4fe6b1b..1673cd24a4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,7 +21,8 @@ system that has a compatible version of Python installed. To check whether you have Python installed and to find its version, just type ``python`` at the CMD or shell prompt. -Python 3.8 or higher is currently required to run. +Python 3.8 or higher is currently required to run the bot but Python 3.9 or +higher is recommended. Python 3.8 support will be dropped with Pywikibot 11 soon. Pywikibot and this documentation are licensed under the :ref:`MIT license`; diff --git a/pywikibot/__init__.py b/pywikibot/__init__.py index 9b35fde295..4ddaf22dae 100644 --- a/pywikibot/__init__.py +++ b/pywikibot/__init__.py @@ -59,7 +59,7 @@ ) from pywikibot.site import BaseSite as _BaseSite from pywikibot.time import Timestamp -from pywikibot.tools import normalize_username +from pywikibot.tools import PYTHON_VERSION, normalize_username if TYPE_CHECKING: @@ -87,6 +87,15 @@ _sites: dict[str, APISite] = {} +if PYTHON_VERSION < (3, 9): + __version = sys.version.split(maxsplit=1)[0] + warnings.warn(f""" + + Python {__version} will be dropped soon with Pywikibot 11. + It is recommended to use Python 3.9 or above. + See phab: T401802 for further information. +""", FutureWarning) # adjust warnings.warn line no in utils.execute() + @cache def _code_fam_from_url(url: str, name: str | None = None) -> tuple[str, str]: diff --git a/tests/utils.py b/tests/utils.py index cf5860fa5b..870ca14649 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -25,6 +25,7 @@ from pywikibot.exceptions import APIError from pywikibot.login import LoginStatus from pywikibot.site import Namespace +from pywikibot.tools import PYTHON_VERSION from pywikibot.tools.collections import EMPTY_DEFAULT from tests import _pwb_py @@ -474,6 +475,9 @@ def execute(command: list[str], *, data_in=None, timeout=None): :param command: executable to run and arguments to use """ + if PYTHON_VERSION < (3, 9): + command.insert(1, '-W ignore::FutureWarning:pywikibot:92') + env = os.environ.copy() # Prevent output by test package; e.g. 'max_retries reduced from x to y' From 6fc196f9b34bbaa7aeb104301a6c12c4f52523e9 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 28 Sep 2025 20:13:00 +0200 Subject: [PATCH 229/279] [IMPR] add union_generators function to tools.itertools This can be used to merge sorted generators like in pagereferences() Change-Id: Ibc1c2ef362a0703717b3331afb10cc238b61c9c8 --- pywikibot/tools/itertools.py | 61 ++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/pywikibot/tools/itertools.py b/pywikibot/tools/itertools.py index e5911ba265..d802c5698a 100644 --- a/pywikibot/tools/itertools.py +++ b/pywikibot/tools/itertools.py @@ -4,19 +4,25 @@ in :mod:`backports` """ # -# (C) Pywikibot team, 2008-2024 +# (C) Pywikibot team, 2008-2025 # # Distributed under the terms of the MIT license. # from __future__ import annotations import collections +import heapq import itertools from contextlib import suppress -from itertools import chain, zip_longest from typing import Any -from pywikibot.backports import Generator, batched +from pywikibot.backports import ( + Callable, + Generator, + Iterable, + Iterator, + batched, +) from pywikibot.logging import debug from pywikibot.tools import deprecated @@ -27,6 +33,7 @@ 'islice_with_ellipsis', 'itergroup', 'roundrobin_generators', + 'union_generators', ) @@ -90,6 +97,47 @@ def islice_with_ellipsis(iterable, *args, marker: str = '…'): yield marker +def union_generators(*iterables: Iterable[Any], + key: Callable[[Any], Any] | None = None, + reverse: bool = False) -> Iterator[Any]: + """Generator of union of sorted iterables. + + Yield all items from the input iterables in sorted order, removing + duplicates. The input iterables must already be sorted according to + the same *key* and direction. For descending direction, *reverse* + must be ``True``. The generator will yield each element only once, + even if it appears in multiple iterables. This behaves similarly to: + + sorted(set(itertools.chain(*iterables)), key=key, reverse=reverse) + + but is memory-efficient since it processes items lazily. + + Sample: + + >>> list(union_generators([1, 2, 3, 4], [3, 4, 5], [2, 6])) + [1, 2, 3, 4, 5, 6] + >>> list(union_generators([4, 3, 2, 1], [5, 4, 3], [6, 2], reverse=True)) + [6, 5, 4, 3, 2, 1] + + .. versionadded:: 10.6 + + .. note:: + All input iterables must be sorted consistently. *reverse* must + be set to ``True`` only if the iterables are sorted in descending + order. For simple concatenation without duplicate removal, use + :pylib:`itertools.chain` instead. + + :param iterables: Sorted iterables to merge. + :param key: Optional key function to compare elements. If ``None``, + items are compared directly. + :param reverse: Whether the input iterables are sorted in descending + order. + :return: Generator yielding all unique items in sorted order. + """ + merged = heapq.merge(*iterables, key=key, reverse=reverse) + return (list(group)[0] for _, group in itertools.groupby(merged, key=key)) + + def intersect_generators(*iterables, allow_duplicates: bool = False): """Generator of intersect iterables. @@ -155,7 +203,7 @@ def intersect_generators(*iterables, allow_duplicates: bool = False): # Get items from iterables in a round-robin way. sentinel = object() - for items in zip_longest(*iterables, fillvalue=sentinel): + for items in itertools.zip_longest(*iterables, fillvalue=sentinel): for index, item in enumerate(items): if item is sentinel: @@ -184,7 +232,8 @@ def intersect_generators(*iterables, allow_duplicates: bool = False): # a subset of active iterables. if len(active_iterables) < n_gen: cached_iterables = set( - chain.from_iterable(v.keys() for v in cache.values())) + itertools.chain.from_iterable(v.keys() + for v in cache.values())) if cached_iterables <= active_iterables: return @@ -210,7 +259,7 @@ def roundrobin_generators(*iterables) -> Generator[Any, None, None]: sentinel = object() return (item for item in itertools.chain.from_iterable( - zip_longest(*iterables, fillvalue=sentinel)) + itertools.zip_longest(*iterables, fillvalue=sentinel)) if item is not sentinel) From d063f687709cc514d85143cad1340b752d944ba6 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 12 Oct 2025 17:52:58 +0200 Subject: [PATCH 230/279] tests: meta login is required for superset test Change-Id: I1d793e97dd555c5e4086d31def18c21814d2e681 --- .github/workflows/oauth_tests-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index ca21d53e27..5895226190 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -87,6 +87,7 @@ jobs: run: | python -Werror::UserWarning -m pwb generate_user_files -family:${{matrix.family}} -lang:${{matrix.code}} -user:${{ env.PYWIKIBOT_USERNAME }} -v -debug; echo "usernames['commons']['beta'] = '${{ env.PYWIKIBOT_USERNAME }}'" >> user-config.py + echo "usernames['meta']['meta'] = '${{ env.PYWIKIBOT_USERNAME }}'" >> user-config.py echo "authenticate['${{ matrix.domain }}'] = ('${{ steps.split.outputs._0 }}', '${{ steps.split.outputs._1 }}', '${{ steps.split.outputs._2 }}', '${{ steps.split.outputs._3 }}')" >> user-config.py echo "noisysleep = float('inf')" >> user-config.py echo "maximum_GET_length = 5000" >> user-config.py From b43bfbe599e7ba6b756df3914dc2ad0c9ff9de0a Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 12 Oct 2025 13:30:17 +0200 Subject: [PATCH 231/279] IMPR: Follow-up for Citoid Query interface implementation - use dataclass for CitoidClient - update type hints - move mypy tests for citoid from tox-typing to pre-commit - add Python script entry point idiom for citoid_tests to allow running it as a standalone script - add citoid module to documentation - update CONTENT.rst Bug: T401690 Change-Id: I02b9214dc87e630323a61e9f6055ab7b0aeb5f59 --- .pre-commit-config.yaml | 2 +- conftest.py | 2 +- docs/api_ref/pywikibot.data.rst | 6 ++++++ pywikibot/CONTENT.rst | 16 ++++++++++++++-- pywikibot/data/citoid.py | 15 ++++++++++----- pywikibot/site/_upload.py | 4 ++-- tests/citoid_tests.py | 5 +++++ 7 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f934067bb1..8201cd3c1c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -129,7 +129,7 @@ repos: (__metadata__|backports|config|cosmetic_changes|daemonize|diff|echo|exceptions|fixes|logging|plural|time|titletranslate)| (comms|data|families|specialbots)/__init__| comms/eventstreams| - data/(api/(__init__|_optionset)|memento|wikistats)| + data/(api/(__init__|_optionset)|citoid|memento|wikistats)| families/[a-z][a-z\d]+_family| page/(__init__|_decorators|_page|_revision)| pagegenerators/(__init__|_filters)| diff --git a/conftest.py b/conftest.py index a314ee8863..2f93dee192 100644 --- a/conftest.py +++ b/conftest.py @@ -20,7 +20,7 @@ r'exceptions|fixes|logging|plural|time|titletranslate)|' r'(comms|data|families|specialbots)/__init__|' r'comms/eventstreams|' - r'data/(api/(__init__|_optionset)|memento|wikistats)|' + r'data/(api/(__init__|_optionset)|citoid|memento|wikistats)|' r'families/[a-z][a-z\d]+_family|' r'page/(__init__|_decorators|_page|_revision)|' r'pagegenerators/(__init__|_filters)|' diff --git a/docs/api_ref/pywikibot.data.rst b/docs/api_ref/pywikibot.data.rst index 0caf1b9734..e6612b63d4 100644 --- a/docs/api_ref/pywikibot.data.rst +++ b/docs/api_ref/pywikibot.data.rst @@ -11,6 +11,12 @@ .. automodule:: data.api :synopsis: Module providing several layers of data access to the wiki +:mod:`data.citoid` --- Citoid Requests +====================================== + +.. automodule:: data.citoid + :synopsis: Citoid Query interface + :mod:`data.memento` --- Memento Requests ======================================== diff --git a/pywikibot/CONTENT.rst b/pywikibot/CONTENT.rst index 2ff8924d94..8fc54f8cfb 100644 --- a/pywikibot/CONTENT.rst +++ b/pywikibot/CONTENT.rst @@ -87,7 +87,9 @@ The contents of the package +----------------------------+------------------------------------------------------+ | data | Module providing layers of data access to wiki | +============================+======================================================+ - | api.py | Interface Module to MediaWiki's api | + | __init__.py | WaitingMixin: A mixin to implement wait cycles | + +----------------------------+------------------------------------------------------+ + | api (folder) | Interface Module to MediaWiki's api | | +----------------+-------------------------------------+ | | __init__.py | Interface to MediaWiki's api.php | | +----------------+-------------------------------------+ @@ -99,12 +101,16 @@ The contents of the package | +----------------+-------------------------------------+ | | _requests.py | API Requests interface | +----------------------------+----------------+-------------------------------------+ + | citoid.py | Citoid Query interface | + +----------------------------+------------------------------------------------------+ | memento.py | memento_client 0.6.1 package fix | +----------------------------+------------------------------------------------------+ | mysql.py | Miscellaneous helper functions for mysql queries | +----------------------------+------------------------------------------------------+ | sparql.py | Objects representing SPARQL query API | +----------------------------+------------------------------------------------------+ + | superset.py | Superset Query interface | + +----------------------------+------------------------------------------------------+ | wikistats.py | Objects representing WikiStats API | +----------------------------+------------------------------------------------------+ @@ -140,6 +146,8 @@ The contents of the package +----------------------------+------------------------------------------------------+ | pagegenerators | Page generators module | +============================+======================================================+ + | __init__.py | Page generators options and special page generators | + +----------------------------+------------------------------------------------------+ | _factory.py | Generator factory class to handle options | +----------------------------+------------------------------------------------------+ | _filter.py | Filter functions | @@ -196,6 +204,8 @@ The contents of the package +----------------------------+------------------------------------------------------+ | _tokenwallet.py | Objects representing api tokens | +----------------------------+------------------------------------------------------+ + | _upload.py | Objects representing API upload to MediaWiki sites | + +----------------------------+------------------------------------------------------+ +----------------------------+------------------------------------------------------+ @@ -236,10 +246,12 @@ The contents of the package +----------------------------+------------------------------------------------------+ - | User Interface | + | userinterfaces | User Interfaces | +============================+======================================================+ | _interface_base.py | Abstract base user interface module | +----------------------------+------------------------------------------------------+ + | buffer_interface.py | Non-interactive interface that stores output | + +----------------------------+------------------------------------------------------+ | gui.py | GUI with a Unicode textfield where the user can edit | +----------------------------+------------------------------------------------------+ | terminal_interface.py | Platform independent terminal interface module | diff --git a/pywikibot/data/citoid.py b/pywikibot/data/citoid.py index ee4a9303db..96a432691b 100644 --- a/pywikibot/data/citoid.py +++ b/pywikibot/data/citoid.py @@ -1,4 +1,4 @@ -"""Superset Query interface. +"""Citoid Query interface. .. versionadded:: 10.6 """ @@ -10,6 +10,8 @@ from __future__ import annotations import urllib.parse +from dataclasses import dataclass +from typing import Any import pywikibot from pywikibot.comms import http @@ -22,6 +24,7 @@ ] +@dataclass(eq=False) class CitoidClient: """Citoid client class. @@ -29,11 +32,13 @@ class CitoidClient: This class allows to call the Citoid API used in production. """ - def __init__(self, site: BaseSite): - """Initialize the CitoidClient.""" - self.site = site + site: BaseSite - def get_citation(self, response_format: str, ref_url: str) -> dict: + def get_citation( + self, + response_format: str, + ref_url: str + ) -> dict[str, Any]: """Get a citation from the citoid service. :param response_format: Return format, e.g. 'bibtex', 'wikibase', etc. diff --git a/pywikibot/site/_upload.py b/pywikibot/site/_upload.py index 2477d3fe5c..9ce19a188d 100644 --- a/pywikibot/site/_upload.py +++ b/pywikibot/site/_upload.py @@ -1,6 +1,6 @@ -"""Objects representing API upload to MediaWiki site.""" +"""Objects representing API upload to MediaWiki sites.""" # -# (C) Pywikibot team, 2009-2024 +# (C) Pywikibot team, 2009-2025 # # Distributed under the terms of the MIT license. # diff --git a/tests/citoid_tests.py b/tests/citoid_tests.py index b4a6c96146..ce1fff5bb1 100755 --- a/tests/citoid_tests.py +++ b/tests/citoid_tests.py @@ -8,6 +8,7 @@ from __future__ import annotations import datetime +import unittest import pywikibot from pywikibot.data import citoid @@ -59,3 +60,7 @@ def test_citoid_no_valid_format(self): 'mediawiki2', 'https://ro.wikipedia.org/wiki/România' ) + + +if __name__ == '__main__': + unittest.main() From bdbfd125fb5401315cf3f13214e83de1dfe27fc2 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 12 Oct 2025 14:32:11 +0200 Subject: [PATCH 232/279] doc: Update ROADMAP.rst Change-Id: If4cd839b84b7eeb8f15846a4984dc5550cc46fc3 --- ROADMAP.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index e1a8b9e6d7..46a13ea4be 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,7 +1,11 @@ Current Release Changes ======================= -* Python 3.8 support will be discontinued and probably this is the last version supporting it. +* Added :func:`tools.itertools.union_generators` for sorted merging of pre-sorted iterables. +* **Support for Python 3.8 will be discontinued**; + this is likely the last Pywikibot version to support it. +* Added a Citoid Query interface with the :mod:`data.citoid` module. +* Updated localization (L10N) files. * :meth:`Family.interwiki_replacements` is deprecated; use :attr:`Family.code_aliases` instead. * The first parameter of :meth:`Transliterator.transliterate From b33bef762ad0c67cb59c80c2df5e4991c1b7f838 Mon Sep 17 00:00:00 2001 From: Xqt Date: Mon, 13 Oct 2025 09:02:00 +0000 Subject: [PATCH 233/279] tests: expected failure in TestSupersetWithAuth.test_login_and_oauth_permission Change-Id: I7c34d4308a7a7fb02bab50807dd60a47f51b0946 Signed-off-by: Xqt --- tests/superset_tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/superset_tests.py b/tests/superset_tests.py index 32adeee67a..7977f2fdab 100755 --- a/tests/superset_tests.py +++ b/tests/superset_tests.py @@ -52,6 +52,7 @@ class TestSupersetWithAuth(TestCase): family = 'meta' code = 'meta' + @unittest.expectedFailure # T395664 def test_login_and_oauth_permission(self) -> None: """Superset login and queries.""" sql = 'SELECT page_id, page_title FROM page LIMIT 2;' From df083c7002651d91db4a399a2b0a55be4dab60bb Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 13 Oct 2025 14:28:36 +0200 Subject: [PATCH 234/279] Update git submodules * Update scripts/i18n from branch 'master' to ab1f8dd8b8a9fe261e9edebc9e9beccdc1deb89b - Localisation updates from https://translatewiki.net. Change-Id: Ib6125353cab02f11f9d8675fb129735ac86dc41a --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 1b771c1b8f..ab1f8dd8b8 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 1b771c1b8f526cd4c9f4baec614cb0ff7b5db879 +Subproject commit ab1f8dd8b8a9fe261e9edebc9e9beccdc1deb89b From 168757abd94b017114f085102aee3b81dc82f072 Mon Sep 17 00:00:00 2001 From: Xqt Date: Mon, 13 Oct 2025 15:02:49 +0000 Subject: [PATCH 235/279] [bugfix] Fix initializer for bot_choice.UnhandledAnswer Change-Id: I2b2a8986502edd97101d45331c577194560f70b1 Signed-off-by: Xqt --- pywikibot/bot_choice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pywikibot/bot_choice.py b/pywikibot/bot_choice.py index 23785e297d..14bfb4fc22 100644 --- a/pywikibot/bot_choice.py +++ b/pywikibot/bot_choice.py @@ -603,7 +603,7 @@ class UnhandledAnswer(Exception): # noqa: N818 """The given answer didn't suffice.""" - def __int__(self, stop: bool = False) -> None: + def __init__(self, stop: bool = False) -> None: """Initializer.""" self.stop = stop From aff971684ccc9ddcc4fe3f24cd62a7d6583cb263 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 14 Oct 2025 08:40:36 +0200 Subject: [PATCH 236/279] IMPR: get regex from textlib in CosmeticChangesToolkit Change-Id: I176a06be2773901ee7f611aa7e4be28bbd466661 --- pywikibot/cosmetic_changes.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pywikibot/cosmetic_changes.py b/pywikibot/cosmetic_changes.py index 2d87ed6946..467734c611 100644 --- a/pywikibot/cosmetic_changes.py +++ b/pywikibot/cosmetic_changes.py @@ -518,9 +518,7 @@ def replace_magicword(match: Match[str]) -> str: cache: dict[bool | str, Any] = {} exceptions = ['comment', 'nowiki', 'pre', 'syntaxhighlight'] - regex = re.compile( - textlib.FILE_LINK_REGEX % '|'.join(self.site.namespaces[6]), - flags=re.VERBOSE) + regex = textlib.get_regexes('file', self.site)[0] return textlib.replaceExcept( text, regex, replace_magicword, exceptions) From 83fbe3b8bddbd810a78d05cee902979174c92495 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 11 Oct 2025 11:11:21 +0200 Subject: [PATCH 237/279] Cleanup: Deprecate isPublic method of Family class Bug: T407049 Change-Id: I299347bf4eef74d9bb7dbc94db4fa30cf4f3634f --- pywikibot/family.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pywikibot/family.py b/pywikibot/family.py index 73c43796a7..390e8b005c 100644 --- a/pywikibot/family.py +++ b/pywikibot/family.py @@ -703,8 +703,12 @@ def shared_image_repository(self, code): """Return the shared image repository, if any.""" return (None, None) + @deprecated(since='10.6.0') def isPublic(self, code) -> bool: - """Check the wiki require logging in before viewing it.""" + """Check the wiki require logging in before viewing it. + + .. deprecated:: 10.6 + """ return True def post_get_convert(self, site, getText): From 1d15d1edb66147c6dcd88f1299c9f14135d802af Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 18 Oct 2025 13:05:41 +0200 Subject: [PATCH 238/279] Tests: Fix MockSite in dry_api_tests - remove unused methods and properties - use langs property with MockFamily and remove unused name - pass username to BaseSite initializer - add "id" to "_userinfo" to be used with logged_in() method - add tests for username() and user() to test login state Change-Id: I6cfd104a08b3ccb6a2a2fd24787c70e9e4cf25de --- tests/dry_api_tests.py | 46 ++++++++++-------------------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/tests/dry_api_tests.py b/tests/dry_api_tests.py index fbef7d0158..3f5520b435 100755 --- a/tests/dry_api_tests.py +++ b/tests/dry_api_tests.py @@ -29,7 +29,6 @@ TestCase, unittest, ) -from tests.utils import DummySiteinfo class DryCachedRequestTests(SiteAttributeTestCase): @@ -149,49 +148,21 @@ def setUp(self) -> None: class MockFamily(Family): @property - def name(self) -> str: - return 'mock' + def langs(self) -> str: + return {'mock': ''} class MockSite(pywikibot.site.APISite): _loginstatus = LoginStatus.NOT_ATTEMPTED - _namespaces = {2: ['User']} def __init__(self) -> None: - self._user = 'anon' - pywikibot.site.BaseSite.__init__(self, 'mock', MockFamily()) - self._siteinfo = DummySiteinfo({'case': 'first-letter'}) - - def version(self) -> str: - return '1.31' # lowest supported release - - def protocol(self) -> str: - return 'http' - - @property - def codes(self): - return {'mock'} - - def user(self): - return self._user - - def encoding(self) -> str: - return 'utf-8' - - def encodings(self): - return [] - - @property - def siteinfo(self): - return self._siteinfo + pywikibot.site.BaseSite.__init__( + self, 'mock', MockFamily(), 'MyUser') def __repr__(self) -> str: return 'MockSite()' - def __getattr__(self, attr): - raise Exception(f'Attribute {attr!r} not defined') - self.mocksite = MockSite() super().setUp() @@ -201,15 +172,18 @@ def test_cachefile_path_different_users(self) -> None: parameters={'action': 'query', 'meta': 'siteinfo'}) anonpath = req._cachefile_path() - self.mocksite._userinfo = {'name': 'MyUser'} + self.assertIsNone(self.mocksite.user()) + + self.mocksite._userinfo = {'name': 'MyUser', 'id': 4711} self.mocksite._loginstatus = LoginStatus.AS_USER req = CachedRequest(expiry=1, site=self.mocksite, parameters={'action': 'query', 'meta': 'siteinfo'}) userpath = req._cachefile_path() self.assertNotEqual(anonpath, userpath) + self.assertEqual(self.mocksite.user(), 'MyUser') - self.mocksite._userinfo = {'name': 'MyOtherUser'} + self.mocksite._userinfo = {'name': 'MyOtherUser', 'id': 4712} self.mocksite._loginstatus = LoginStatus.AS_USER req = CachedRequest(expiry=1, site=self.mocksite, parameters={'action': 'query', 'meta': 'siteinfo'}) @@ -217,6 +191,8 @@ def test_cachefile_path_different_users(self) -> None: self.assertNotEqual(anonpath, otherpath) self.assertNotEqual(userpath, otherpath) + self.assertIsNone(self.mocksite.user()) + self.assertEqual(self.mocksite.username(), 'MyUser') def test_unicode(self) -> None: """Test caching with Unicode content.""" From 17b0263f93b78ec63137063c742907dad62990d4 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 18 Oct 2025 13:33:51 +0200 Subject: [PATCH 239/279] coverage: hide unrelated code from coverage Change-Id: I218c7ee5a2cd007c97742004122c777dfc03dc84 --- tests/eventstreams_tests.py | 2 +- tests/gui_tests.py | 2 +- tests/interwikidata_tests.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/eventstreams_tests.py b/tests/eventstreams_tests.py index e125901977..db51e41994 100755 --- a/tests/eventstreams_tests.py +++ b/tests/eventstreams_tests.py @@ -141,7 +141,7 @@ def test_filter_function_settings(self) -> None: """Test EventStreams filter function settings.""" def foo() -> bool: """Dummy function.""" - return True + return True # pragma: no cover self.es.register_filter(foo) self.assertEqual(self.es.filter['all'][0], foo) diff --git a/tests/gui_tests.py b/tests/gui_tests.py index daad444ff3..9ff4a7d2cb 100755 --- a/tests/gui_tests.py +++ b/tests/gui_tests.py @@ -92,7 +92,7 @@ def setUpModule() -> None: try: dialog = tkinter.Tk() - except RuntimeError as e: + except RuntimeError as e: # pragma: no cover raise unittest.SkipTest(f'Skipping due to T380732 - {e}') dialog.destroy() diff --git a/tests/interwikidata_tests.py b/tests/interwikidata_tests.py index 97291ef29a..059064ff86 100755 --- a/tests/interwikidata_tests.py +++ b/tests/interwikidata_tests.py @@ -24,11 +24,11 @@ class DummyBot(interwikidata.IWBot): def put_current(self, *args: Any, **kwargs: Any) -> bool: """Prevent editing.""" - return False + raise NotImplementedError - def create_item(self) -> bool: + def create_item(self) -> pywikibot.ItemPage: """Prevent creating items.""" - return False + raise NotImplementedError def try_to_add(self) -> None: """Prevent adding sitelinks to items.""" From f493b1c8f80e450134307cbbcf52fc09d89f99ed Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 18 Oct 2025 15:00:01 +0200 Subject: [PATCH 240/279] Tests: Fix skip message in CheckHostnameMixin Change-Id: I2457246c5f1dd5f01d7540f5a148ed5ffeebd7dd --- tests/aspects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/aspects.py b/tests/aspects.py index 4ddcc35918..c76cfca519 100644 --- a/tests/aspects.py +++ b/tests/aspects.py @@ -655,8 +655,8 @@ def setUpClass(cls) -> None: f'{cls.__name__}: accessing {hostname} caused exception:') cls._checked_hostnames[hostname] = e - raise unittest.SkipTest(f'{cls.__name__}: hostname {hostname}' - ' failed: {e}') from None + raise unittest.SkipTest(f'{cls.__name__}: hostname {hostname} ' + f'failed: {e}') from None cls._checked_hostnames[hostname] = True From a43550536f48427f0a3eb6341cf9219dfa7ed3fa Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 18 Oct 2025 14:43:57 +0200 Subject: [PATCH 241/279] Tests: Update Pillow requirements and add pypy3.11 tests Bug: T407691 Change-Id: Id7757b4b2a94857895c9075a32cc61664fa1db7b --- .github/workflows/doctest.yml | 2 +- .github/workflows/login_tests-ci.yml | 2 +- .github/workflows/oauth_tests-ci.yml | 2 +- .github/workflows/pywikibot-ci.yml | 2 +- requirements.txt | 5 ++++- setup.py | 7 ++++++- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/doctest.yml b/.github/workflows/doctest.yml index 11bf199a65..781bee6a78 100644 --- a/.github/workflows/doctest.yml +++ b/.github/workflows/doctest.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false max-parallel: 17 matrix: - python-version: [pypy3.8, pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: [pypy3.8, pypy3.11, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] os: ['windows-latest', 'macOS-latest', 'ubuntu-latest'] include: - python-version: 3.15-dev diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index b52bc6440c..fbd50728b9 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -42,7 +42,7 @@ jobs: fail-fast: false max-parallel: 1 matrix: - python-version: [pypy3.8, pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 3.15-dev] + python-version: [pypy3.8, pypy3.11, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 3.15-dev] site: ['wikipedia:en', 'wikisource:zh', 'wikipedia:test'] include: - python-version: '3.8' diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 5895226190..592d5908f9 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [pypy3.8, pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 3.15-dev] + python-version: [pypy3.8, pypy3.11, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 3.15-dev] family: [wikipedia] code: [test] domain: [test.wikipedia.org] diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index d55a219160..db37b206fa 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -25,7 +25,7 @@ jobs: fail-fast: false max-parallel: 19 matrix: - python-version: [pypy3.10, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: [pypy3.10, pypy3.11, '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] site: ['wikipedia:en', 'wikisource:zh'] include: - python-version: '3.8' diff --git a/requirements.txt b/requirements.txt index c6f3ef33b0..8b2ec76c04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,8 +42,11 @@ pydot >= 3.0.2 python-stdnum >= 1.20 # GUI -Pillow>=11.1.0; python_version > "3.8" Pillow==10.4.0; python_version < "3.9" +Pillow>=11.1.0,<11.3.0; python_version == "3.9" +Pillow>=11.1.0,<11.3.0; platform_python_implementation == "PyPy" and python_version < "3.11" +Pillow>=11.1.0; platform_python_implementation == "PyPy" and python_version >= "3.11" +Pillow>=11.1.0; python_version >= "3.10" # core pagegenerators googlesearch-python >= 1.3.0 diff --git a/setup.py b/setup.py index 80d62fa993..1d199af599 100755 --- a/setup.py +++ b/setup.py @@ -46,8 +46,13 @@ 'mysql': ['PyMySQL >= 1.1.1'], # vulnerability found in Pillow<8.1.2 but toolforge uses 5.4.1 'Tkinter': [ - 'Pillow>=11.1.0; python_version > "3.8"', 'Pillow==10.4.0; python_version < "3.9"', + 'Pillow>=11.1.0,<11.3.0; python_version == "3.9"', + 'Pillow>=11.1.0,<11.3.0; platform_python_implementation == "PyPy" ' + 'and python_version < "3.11"', + 'Pillow>=11.1.0; platform_python_implementation == "PyPy" ' + 'and python_version >= "3.11"', + 'Pillow>=11.1.0; python_version >= "3.10"', ], 'mwoauth': [ 'PyJWT != 2.10.0, != 2.10.1; python_version > "3.8"', # T380270 From f407a553b1bd7f8fd75a0c940202aaee7ec6efad Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 12 Oct 2025 15:32:26 +0200 Subject: [PATCH 242/279] IMPR: Show the related site with NoUsernameError during LoginManager.login() Change-Id: Iaeef4107a3707c2332860e7101f5cbc62b568ecd --- pywikibot/login.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pywikibot/login.py b/pywikibot/login.py index 1dcb5039b6..94650e93eb 100644 --- a/pywikibot/login.py +++ b/pywikibot/login.py @@ -153,9 +153,8 @@ def check_user_exists(self) -> None: if user['name'] != main_username: # Report the same error as server error code NotExists - raise NoUsernameError( - f"Username '{main_username}' does not exist on {self.site}" - ) + msg = f"Username '{main_username}' does not exist on {self.site}" + raise NoUsernameError(msg) def botAllowed(self) -> bool: """Check whether the bot is listed on a specific page. @@ -322,7 +321,7 @@ def login(self, retry: bool = False, autocreate: bool = False) -> bool: if error_code in ('NotExists', 'Illegal', 'readapidenied', 'Failed', 'Aborted', 'FAIL'): - error_msg = f'{e.code}: {e.info}' + error_msg = f'{e.code} on {self.site}: {e.info}' raise NoUsernameError(error_msg) pywikibot.error(f'Login failed ({error_code}).') From c585dbf7ff3ee9f8372b5633e06eb7298730a4a6 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 19 Oct 2025 12:05:26 +0200 Subject: [PATCH 243/279] coverage: Remove fallback codes from coverage Change-Id: I013768579f81ca00e1e30b6111098a03ff715948 --- tests/oauth_tests.py | 4 ++-- tests/page_tests.py | 5 +++-- tests/pagegenerators_tests.py | 2 +- tests/proofreadpage_tests.py | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/oauth_tests.py b/tests/oauth_tests.py index 3f95cdea05..20a3fdb1d1 100755 --- a/tests/oauth_tests.py +++ b/tests/oauth_tests.py @@ -80,7 +80,7 @@ def test_edit(self) -> None: p = pywikibot.Page(self.site, title) try: p.site.editpage(p, appendtext='\n' + ts) - except EditConflictError as e: + except EditConflictError as e: # pragma: no cover self.assertEqual(e.page, p) else: revision_id = p.latest_revision_id @@ -89,7 +89,7 @@ def test_edit(self) -> None: if revision_id == p.latest_revision_id: self.assertEndsWith(p.text, ts) else: - self.assertIn(ts, t) + self.assertIn(ts, t) # pragma: no cover class TestOauthLoginManager(DefaultSiteTestCase, OAuthSiteTestCase): diff --git a/tests/page_tests.py b/tests/page_tests.py index cf7e866531..a023de2374 100755 --- a/tests/page_tests.py +++ b/tests/page_tests.py @@ -1014,6 +1014,7 @@ def testIsStaticRedirect(self) -> None: def testPageGet(self) -> None: """Test ``Page.get()`` on different types of pages.""" fail_msg = '{page!r}.get() raised {error!r} unexpectedly!' + unexpected_exceptions = IsRedirectPageError, NoPageError, SectionError site = self.get_site('en') p1 = pywikibot.Page(site, 'User:Legoktm/R2') p2 = pywikibot.Page(site, 'User:Legoktm/R1') @@ -1029,7 +1030,7 @@ def testPageGet(self) -> None: try: p2.get(get_redirect=True) - except (IsRedirectPageError, NoPageError, SectionError) as e: + except unexpected_exceptions as e: # pragma: no cover self.fail(fail_msg.format(page=p2, error=e)) with self.assertRaisesRegex(NoPageError, NO_PAGE_RE): @@ -1044,7 +1045,7 @@ def testPageGet(self) -> None: page = pywikibot.Page(site, 'Manual:Pywikibot/2.0 #See_also') try: page.get() - except (IsRedirectPageError, NoPageError, SectionError) as e: + except unexpected_exceptions as e: # pragma: no cover self.fail(fail_msg.format(page=page, error=e)) def test_set_redirect_target(self) -> None: diff --git a/tests/pagegenerators_tests.py b/tests/pagegenerators_tests.py index f2aa0b4a9a..2cf9661dbe 100755 --- a/tests/pagegenerators_tests.py +++ b/tests/pagegenerators_tests.py @@ -1453,7 +1453,7 @@ def test_wanted_files(self) -> None: for page in self._generator_with_tests(): self.assertIsInstance(page, pywikibot.Page) if not isinstance(page, pywikibot.FilePage): - with self.assertRaisesRegex(ValueError, + with self.assertRaisesRegex(ValueError, # pragma: no cover 'does not have a valid extension'): pywikibot.FilePage(page) else: diff --git a/tests/proofreadpage_tests.py b/tests/proofreadpage_tests.py index 94b20ecb18..55c4da0115 100755 --- a/tests/proofreadpage_tests.py +++ b/tests/proofreadpage_tests.py @@ -500,7 +500,7 @@ def test_ocr_wmfocr(self) -> None: """Test page.ocr(ocr_tool='wmfOCR').""" try: text = self.page.ocr(ocr_tool='wmfOCR') - except Exception as exc: + except Exception as exc: # pragma: no cover self.assertIsInstance(exc, ValueError) else: ref_text = self.data['wmfOCR'] From d0dad011d3fb6d0d055ad5d1e758d2068d9aa1d2 Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 19 Oct 2025 12:12:29 +0200 Subject: [PATCH 244/279] Tests: fix TestIndexPageMappings.test_get_page_and_number tests - remove duplicate error tests - test get_page and get_number with non-empty lists Change-Id: I602e31dcb8f52017bc7651bd0034e15ff16e783b --- tests/proofreadpage_tests.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/proofreadpage_tests.py b/tests/proofreadpage_tests.py index 55c4da0115..cad13bc3bb 100755 --- a/tests/proofreadpage_tests.py +++ b/tests/proofreadpage_tests.py @@ -791,11 +791,6 @@ def test_get_page_and_number(self, key) -> None: self.assertEqual(index_page.get_page_number_from_label(str(label)), num_set) - # Error if label does not exists. - label, num_set = 'dummy label', [] - with self.assertRaises(KeyError): - index_page.get_page_number_from_label('dummy label') - # Test get_page_from_label. for label, page_set in data['get_page']: # Get set of pages from label with label as int or str. @@ -804,10 +799,6 @@ def test_get_page_and_number(self, key) -> None: self.assertEqual(index_page.get_page_from_label(str(label)), page_set) - # Error if label does not exists. - with self.assertRaises(KeyError): - index_page.get_page_from_label('dummy label') - # Test get_page. for n in num_set: p = index_page.get_page(n) @@ -818,6 +809,10 @@ def test_get_page_and_number(self, key) -> None: n = index_page.get_number(p) self.assertEqual(index_page.get_page(n), p) + # Error if label does not exists. + with self.assertRaises(KeyError): + index_page.get_page_number_from_label('dummy label') + def test_page_gen(self, key) -> None: """Test Index page generator.""" data = self.sites[key] From e8f575e427246b850d2b0d607c1ddc86e9335ab3 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 20 Oct 2025 07:38:06 +0200 Subject: [PATCH 245/279] Update git submodules * Update scripts/i18n from branch 'master' to 023184bc4964c3ea937a7b6480ebc3a645e5784e - Localisation updates from https://translatewiki.net. Change-Id: I0555555582b20dcfaf8867a6867e05a78eaad3ce --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index ab1f8dd8b8..023184bc49 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit ab1f8dd8b8a9fe261e9edebc9e9beccdc1deb89b +Subproject commit 023184bc4964c3ea937a7b6480ebc3a645e5784e From 0f77d883984b6aadce0bdfb0e8864951a621515a Mon Sep 17 00:00:00 2001 From: Strainu Date: Sun, 19 Oct 2025 16:08:34 +0300 Subject: [PATCH 246/279] Wikibase: Fix get_value_at_timestamp We need to keep the rank of the claims in the loop. Bug: T407701 Change-Id: Ie211919095e7d68fc075434b31ea59de9c84a640 Signed-off-by: Strainu --- pywikibot/page/_wikibase.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py index 1ef035b864..f7d99c940f 100644 --- a/pywikibot/page/_wikibase.py +++ b/pywikibot/page/_wikibase.py @@ -1430,12 +1430,16 @@ def find_value_at_timestamp(claims, ts, language): else pywikibot.WbTime(0, site=self.site)), reverse=True ) + best_claim = None for claim in sorted_claims: + if claim.rank == 'deprecated': + continue if timestamp_in_interval(claim, ts): if (claim.type != 'monolingualtext' - or claim.getTarget().language == language): - return claim.getTarget() - return None + or claim.getTarget().language == language)\ + and claim.has_better_rank(best_claim): + best_claim = claim + return best_claim and best_claim.getTarget() if prop in self.claims: return find_value_at_timestamp(self.claims[prop], timestamp, lang) @@ -2235,6 +2239,17 @@ def _formatDataValue(self) -> dict: 'type': self.value_types.get(self.type, self.type) } + def has_better_rank(self, other) -> bool: + """Check if this claim has a better rank than the other claim. + + :param other: The other claim to compare with. + :return: True if this claim has a better rank, False otherwise. + """ + if other is None: + return True + rank_order = {'preferred': 3, 'normal': 2, 'deprecated': 1} + return rank_order.get(self.rank, 0) > rank_order.get(other.rank, 0) + class LexemePage(WikibasePage): From 4fe8da9294e90219dc325c0c93653d255dd93539 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 21 Oct 2025 17:01:56 +0200 Subject: [PATCH 247/279] Tests: Update Pillow requirements Bug: T407691 Change-Id: Iaf9fdc3112cc2cb90ef669e89cd6348777f763f1 --- requirements.txt | 9 +++++---- setup.py | 13 +++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8b2ec76c04..ef34875c02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,11 +42,12 @@ pydot >= 3.0.2 python-stdnum >= 1.20 # GUI -Pillow==10.4.0; python_version < "3.9" -Pillow>=11.1.0,<11.3.0; python_version == "3.9" -Pillow>=11.1.0,<11.3.0; platform_python_implementation == "PyPy" and python_version < "3.11" +Pillow==10.4.0; platform_python_implementation == "PyPy" and python_version < "3.9" +Pillow>=11.1.0,<11.3.0; platform_python_implementation == "PyPy" and python_version > "3.9" and python_version < "3.11" Pillow>=11.1.0; platform_python_implementation == "PyPy" and python_version >= "3.11" -Pillow>=11.1.0; python_version >= "3.10" +Pillow==10.4.0; platform_python_implementation != "PyPy" and python_version < "3.9" +Pillow>=11.1.0,<11.3.0; platform_python_implementation != "PyPy" and python_version == "3.9" +Pillow>=11.1.0; platform_python_implementation != "PyPy" and python_version >= "3.10" # core pagegenerators googlesearch-python >= 1.3.0 diff --git a/setup.py b/setup.py index 1d199af599..28821be1ac 100755 --- a/setup.py +++ b/setup.py @@ -46,13 +46,18 @@ 'mysql': ['PyMySQL >= 1.1.1'], # vulnerability found in Pillow<8.1.2 but toolforge uses 5.4.1 'Tkinter': [ - 'Pillow==10.4.0; python_version < "3.9"', - 'Pillow>=11.1.0,<11.3.0; python_version == "3.9"', + 'Pillow==10.4.0; platform_python_implementation == "PyPy" ' + 'and python_version < "3.9"', 'Pillow>=11.1.0,<11.3.0; platform_python_implementation == "PyPy" ' - 'and python_version < "3.11"', + 'and python_version > "3.9" and python_version < "3.11"', 'Pillow>=11.1.0; platform_python_implementation == "PyPy" ' 'and python_version >= "3.11"', - 'Pillow>=11.1.0; python_version >= "3.10"', + 'Pillow==10.4.0; platform_python_implementation != "PyPy" ' + 'and python_version < "3.9"', + 'Pillow>=11.1.0,<11.3.0; platform_python_implementation != "PyPy" ' + 'and python_version == "3.9"', + 'Pillow>=11.1.0; platform_python_implementation != "PyPy" ' + 'and python_version >= "3.10"', ], 'mwoauth': [ 'PyJWT != 2.10.0, != 2.10.1; python_version > "3.8"', # T380270 From e7502b2a151d481e046d9d3e4e07fa3be17b8f44 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 21 Oct 2025 17:28:09 +0200 Subject: [PATCH 248/279] Tests: Fix Pillow requirements Bug: T407691 Change-Id: I70bfe303c11a86851b6cdb5053066f4a5dddedc6 --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index ef34875c02..42f78a505b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ python-stdnum >= 1.20 # GUI Pillow==10.4.0; platform_python_implementation == "PyPy" and python_version < "3.9" -Pillow>=11.1.0,<11.3.0; platform_python_implementation == "PyPy" and python_version > "3.9" and python_version < "3.11" +Pillow>=11.1.0,<11.3.0; platform_python_implementation == "PyPy" and python_version >= "3.9" and python_version < "3.11" Pillow>=11.1.0; platform_python_implementation == "PyPy" and python_version >= "3.11" Pillow==10.4.0; platform_python_implementation != "PyPy" and python_version < "3.9" Pillow>=11.1.0,<11.3.0; platform_python_implementation != "PyPy" and python_version == "3.9" diff --git a/setup.py b/setup.py index 28821be1ac..806f3ec2e2 100755 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ 'Pillow==10.4.0; platform_python_implementation == "PyPy" ' 'and python_version < "3.9"', 'Pillow>=11.1.0,<11.3.0; platform_python_implementation == "PyPy" ' - 'and python_version > "3.9" and python_version < "3.11"', + 'and python_version >= "3.9" and python_version < "3.11"', 'Pillow>=11.1.0; platform_python_implementation == "PyPy" ' 'and python_version >= "3.11"', 'Pillow==10.4.0; platform_python_implementation != "PyPy" ' From 94fa3955410494480f9b4672b3e7c657d11ffead Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 22 Oct 2025 16:04:31 +0200 Subject: [PATCH 249/279] [10.6] Publish Pywikibot 10.6 Change-Id: Ifca2b4a6aeb8a647a4bbfcbf633637c0c75d55a0 --- ROADMAP.rst | 4 ++++ pywikibot/__init__.py | 11 +---------- pywikibot/__metadata__.py | 2 +- pywikibot/page/_wikibase.py | 4 +++- tests/utils.py | 4 ---- 5 files changed, 9 insertions(+), 16 deletions(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index 46a13ea4be..d2e61bc0fb 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,6 +1,9 @@ Current Release Changes ======================= +* Fix :meth:`ItemPage.get_value_at_timestamp()`; + keep the rank of the claims in the loop. (:phab:`T407701`) +* :meth:`Family.isPublic()` is deprecated (:phab:`T407049`) * Added :func:`tools.itertools.union_generators` for sorted merging of pre-sorted iterables. * **Support for Python 3.8 will be discontinued**; this is likely the last Pywikibot version to support it. @@ -113,6 +116,7 @@ Pending removal in Pywikibot 12 Pending removal in Pywikibot 13 ------------------------------- +* 10.6.0: :meth:`Family.isPublic()` will be removed (:phab:`T407049`) * 10.6.0: :meth:`Family.interwiki_replacements` is deprecated; use :attr:`Family.code_aliases` instead. * Keyword argument for *char* parameter of :meth:`Transliterator.transliterate diff --git a/pywikibot/__init__.py b/pywikibot/__init__.py index 4ddaf22dae..9b35fde295 100644 --- a/pywikibot/__init__.py +++ b/pywikibot/__init__.py @@ -59,7 +59,7 @@ ) from pywikibot.site import BaseSite as _BaseSite from pywikibot.time import Timestamp -from pywikibot.tools import PYTHON_VERSION, normalize_username +from pywikibot.tools import normalize_username if TYPE_CHECKING: @@ -87,15 +87,6 @@ _sites: dict[str, APISite] = {} -if PYTHON_VERSION < (3, 9): - __version = sys.version.split(maxsplit=1)[0] - warnings.warn(f""" - - Python {__version} will be dropped soon with Pywikibot 11. - It is recommended to use Python 3.9 or above. - See phab: T401802 for further information. -""", FutureWarning) # adjust warnings.warn line no in utils.execute() - @cache def _code_fam_from_url(url: str, name: str | None = None) -> tuple[str, str]: diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index 2004918e7b..b9668b1ec9 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.6.0.dev0' +__version__ = '10.6.0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py index f7d99c940f..5da0ea6ad8 100644 --- a/pywikibot/page/_wikibase.py +++ b/pywikibot/page/_wikibase.py @@ -2239,9 +2239,11 @@ def _formatDataValue(self) -> dict: 'type': self.value_types.get(self.type, self.type) } - def has_better_rank(self, other) -> bool: + def has_better_rank(self, other: Claim | None) -> bool: """Check if this claim has a better rank than the other claim. + .. versionadded:: 10.6 + :param other: The other claim to compare with. :return: True if this claim has a better rank, False otherwise. """ diff --git a/tests/utils.py b/tests/utils.py index 870ca14649..cf5860fa5b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -25,7 +25,6 @@ from pywikibot.exceptions import APIError from pywikibot.login import LoginStatus from pywikibot.site import Namespace -from pywikibot.tools import PYTHON_VERSION from pywikibot.tools.collections import EMPTY_DEFAULT from tests import _pwb_py @@ -475,9 +474,6 @@ def execute(command: list[str], *, data_in=None, timeout=None): :param command: executable to run and arguments to use """ - if PYTHON_VERSION < (3, 9): - command.insert(1, '-W ignore::FutureWarning:pywikibot:92') - env = os.environ.copy() # Prevent output by test package; e.g. 'max_retries reduced from x to y' From 68015fd00569b78b51be2ade6aaa04326f8d4e31 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 22 Oct 2025 17:38:43 +0200 Subject: [PATCH 250/279] [10.6] Publish 10.6; ignore BugBear B042 for now Change-Id: I67e79de7b9565b4240e935a2aaaa38787da7bca4 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index d47e26c012..4fe7dadff0 100644 --- a/tox.ini +++ b/tox.ini @@ -149,7 +149,7 @@ deps = # R100: raise in except handler without from # W503: line break before binary operator; against current PEP 8 recommendation -ignore = B007,B028,E704,F824,R100,W503 +ignore = B007,B028,B042,E704,F824,R100,W503 enable-extensions = N818 count = True From ffe104a9ff3216249b661993759acf8cea76e8a4 Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 23 Oct 2025 10:43:51 +0200 Subject: [PATCH 251/279] [10.7] Prepare next release Change-Id: I66db5a0690b5f2a303d14bf96a0667081f27799c --- .pre-commit-config.yaml | 2 +- HISTORY.rst | 26 ++++++++++++++++++++++++++ ROADMAP.rst | 21 +-------------------- dev-requirements.txt | 15 +++++++++------ docs/requirements.txt | 2 +- pywikibot/__init__.py | 11 ++++++++++- pywikibot/__metadata__.py | 2 +- scripts/__init__.py | 2 +- scripts/pyproject.toml | 2 +- tests/utils.py | 4 ++++ 10 files changed, 55 insertions(+), 32 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8201cd3c1c..a150f20aad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,7 +65,7 @@ repos: language: python require_serial: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.0 + rev: v0.14.1 hooks: - id: ruff-check alias: ruff diff --git a/HISTORY.rst b/HISTORY.rst index ed084d555d..23feeedd6d 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,32 @@ Release History =============== +10.6.0 +------ +*23 October 2025* + +* Fix :meth:`ItemPage.get_value_at_timestamp()`; + keep the rank of the claims in the loop. (:phab:`T407701`) +* :meth:`Family.isPublic()` is deprecated (:phab:`T407049`) +* Added :func:`tools.itertools.union_generators` for sorted merging of pre-sorted iterables. +* **Support for Python 3.8 will be discontinued**; + this is likely the last Pywikibot version to support it. +* Added a Citoid Query interface with the :mod:`data.citoid` module. +* Updated localization (L10N) files. +* :meth:`Family.interwiki_replacements` is deprecated; + use :attr:`Family.code_aliases` instead. +* The first parameter of :meth:`Transliterator.transliterate + ` is positional only + whereas *prev* and *succ* parameters are keyword only. The :class:`Transliterator + ` was improved. +* Show user-agent with :mod:`version` script (:phab:`T406458`) +* Positional arguments of :func:`daemonize()` are deprecated and must + be given as keyword arguments. +* i18n updates. +* Return :meth:`bot.BaseBot.userPut` result from :meth:`AutomaticTWSummaryBot.put_current() + ` method + + 10.5.0 ------ *21 September 2025* diff --git a/ROADMAP.rst b/ROADMAP.rst index d2e61bc0fb..97fec9d265 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,26 +1,7 @@ Current Release Changes ======================= -* Fix :meth:`ItemPage.get_value_at_timestamp()`; - keep the rank of the claims in the loop. (:phab:`T407701`) -* :meth:`Family.isPublic()` is deprecated (:phab:`T407049`) -* Added :func:`tools.itertools.union_generators` for sorted merging of pre-sorted iterables. -* **Support for Python 3.8 will be discontinued**; - this is likely the last Pywikibot version to support it. -* Added a Citoid Query interface with the :mod:`data.citoid` module. -* Updated localization (L10N) files. -* :meth:`Family.interwiki_replacements` is deprecated; - use :attr:`Family.code_aliases` instead. -* The first parameter of :meth:`Transliterator.transliterate - ` is positional only - whereas *prev* and *succ* parameters are keyword only. The :class:`Transliterator - ` was improved. -* Show user-agent with :mod:`version` script (:phab:`T406458`) -* Positional arguments of :func:`daemonize()` are deprecated and must - be given as keyword arguments. -* i18n updates. -* Return :meth:`bot.BaseBot.userPut` result from :meth:`AutomaticTWSummaryBot.put_current() - ` method +* (no changes yet) Deprecations diff --git a/dev-requirements.txt b/dev-requirements.txt index 4f1bef5cb5..cc1bb08e27 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,20 +1,23 @@ # This is a PIP 6+ requirements file for development dependencies # -pytest >= 8.3.5 +pytest >= 8.4.2; python_version > "3.8" +pytest == 8.3.5; python_version < "3.9" pytest-subtests >= 0.14.2; python_version > "3.8" pytest-subtests == 0.13.1; python_version < "3.9" pytest-attrib>=0.1.3 -pytest-xvfb>=3.0.0 +pytest-xvfb>=3.1.1; python_version > "3.8" +pytest-xvfb==3.0.0; python_version < "3.9" pre-commit >= 4.3.0; python_version > "3.8" pre-commit == 3.5.0; python_version < "3.9" +coverage>=7.11.0; python_version > "3.9" +coverage==7.10.7; python_version == "3.9" coverage==7.6.1; python_version < "3.9" -coverage>=7.10.6; python_version > "3.8" # required for coverage (T380697) -tomli>=2.2.1; python_version < "3.11" +tomli>=2.3.0; python_version < "3.11" # optional but needed for tests -fake-useragent >= 2.0.3; python_version > "3.8" -fake-useragent == 1.5.1; python_version < "3.9" +fake-useragent >= 2.2.0; python_version > "3.8" +fake-useragent == 2.1.0; python_version < "3.9" diff --git a/docs/requirements.txt b/docs/requirements.txt index ad2bb0f4ae..40ec37e3fa 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,4 +6,4 @@ rstcheck >=6.2.5 sphinxext-opengraph >= 0.13.0 sphinx-copybutton >= 0.5.2 sphinx-tabs >= 3.4.7 -furo >= 2025.7.19 +furo >= 2025.9.25 diff --git a/pywikibot/__init__.py b/pywikibot/__init__.py index 9b35fde295..4ddaf22dae 100644 --- a/pywikibot/__init__.py +++ b/pywikibot/__init__.py @@ -59,7 +59,7 @@ ) from pywikibot.site import BaseSite as _BaseSite from pywikibot.time import Timestamp -from pywikibot.tools import normalize_username +from pywikibot.tools import PYTHON_VERSION, normalize_username if TYPE_CHECKING: @@ -87,6 +87,15 @@ _sites: dict[str, APISite] = {} +if PYTHON_VERSION < (3, 9): + __version = sys.version.split(maxsplit=1)[0] + warnings.warn(f""" + + Python {__version} will be dropped soon with Pywikibot 11. + It is recommended to use Python 3.9 or above. + See phab: T401802 for further information. +""", FutureWarning) # adjust warnings.warn line no in utils.execute() + @cache def _code_fam_from_url(url: str, name: str | None = None) -> tuple[str, str]: diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index b9668b1ec9..ae061724d8 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.6.0' +__version__ = '10.7.0.dev0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' diff --git a/scripts/__init__.py b/scripts/__init__.py index 4037f0b221..b747241350 100644 --- a/scripts/__init__.py +++ b/scripts/__init__.py @@ -34,7 +34,7 @@ from pathlib import Path -__version__ = '10.6.0' +__version__ = '10.7.0' #: defines the entry point for pywikibot-scripts package base_dir = Path(__file__).parent diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index 4b4e4037b2..67f846a15f 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -7,7 +7,7 @@ package-dir = {"pywikibot_scripts" = "scripts"} [project] name = "pywikibot-scripts" -version = "10.6.0" +version = "10.7.0" authors = [ {name = "xqt", email = "info@gno.de"}, diff --git a/tests/utils.py b/tests/utils.py index cf5860fa5b..870ca14649 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -25,6 +25,7 @@ from pywikibot.exceptions import APIError from pywikibot.login import LoginStatus from pywikibot.site import Namespace +from pywikibot.tools import PYTHON_VERSION from pywikibot.tools.collections import EMPTY_DEFAULT from tests import _pwb_py @@ -474,6 +475,9 @@ def execute(command: list[str], *, data_in=None, timeout=None): :param command: executable to run and arguments to use """ + if PYTHON_VERSION < (3, 9): + command.insert(1, '-W ignore::FutureWarning:pywikibot:92') + env = os.environ.copy() # Prevent output by test package; e.g. 'max_retries reduced from x to y' From 79c4c5949c56575fac97e2523c823fd6297080f2 Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 23 Oct 2025 12:07:49 +0200 Subject: [PATCH 252/279] [bugfix] fix fake-useragent requirement The changelog obviously does not match the pyproject.toml setting in fake_useragent. Change-Id: I0a51269685f6aaf674da384f6812dc7a456c44c2 --- dev-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index cc1bb08e27..db85cfba2e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -20,4 +20,4 @@ tomli>=2.3.0; python_version < "3.11" # optional but needed for tests fake-useragent >= 2.2.0; python_version > "3.8" -fake-useragent == 2.1.0; python_version < "3.9" +fake-useragent == 2.0.0; python_version < "3.9" From edd854bb84f9d9052e0df3e2005ab74f9ddfb44d Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 23 Oct 2025 13:18:46 +0200 Subject: [PATCH 253/279] [bugfix] fix fake-useragent requirement fake_useragent 2.0 leads to TypeError: 'type' object is not subscriptable probably due to missing annotations import. Went back to 1.5.1. Change-Id: I3d59df343dfec79ef5e9d8c0978a3e67a2369252 --- dev-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index db85cfba2e..823b1fbbfe 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -20,4 +20,4 @@ tomli>=2.3.0; python_version < "3.11" # optional but needed for tests fake-useragent >= 2.2.0; python_version > "3.8" -fake-useragent == 2.0.0; python_version < "3.9" +fake-useragent == 1.5.1; python_version < "3.9" From 4f1f83ca17e1064bba68180d9d10d03da8b4297e Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 23 Oct 2025 14:25:08 +0200 Subject: [PATCH 254/279] Update git submodules * Update scripts/i18n from branch 'master' to 247bf9fc6576d2efd7bc9e4bf030544d295ab61c - Localisation updates from https://translatewiki.net. Change-Id: I945d1088b811061d667b3b1569a35457eae0ac88 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 023184bc49..247bf9fc65 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 023184bc4964c3ea937a7b6480ebc3a645e5784e +Subproject commit 247bf9fc6576d2efd7bc9e4bf030544d295ab61c From ba759d1af71147e3490f60aefde34861d6613ab7 Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 23 Oct 2025 16:18:12 +0200 Subject: [PATCH 255/279] tests: Update actions/checkout and actions/setup-python Change-Id: If07b34da2f5e468f2d04dddf254dd15a8fd54669 --- .github/workflows/doctest.yml | 4 ++-- .github/workflows/login_tests-ci.yml | 4 ++-- .github/workflows/oauth_tests-ci.yml | 4 ++-- .github/workflows/pre-commit.yml | 4 ++-- .github/workflows/pywikibot-ci.yml | 4 ++-- .github/workflows/sysop_write_tests-ci.yml | 4 ++-- .github/workflows/windows_tests.yml | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/doctest.yml b/.github/workflows/doctest.yml index 781bee6a78..419bb238b2 100644 --- a/.github/workflows/doctest.yml +++ b/.github/workflows/doctest.yml @@ -28,11 +28,11 @@ jobs: - python-version: 3.15-dev steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 if: "!endsWith(matrix.python-version, '-dev')" with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index fbd50728b9..ea6d5959ab 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -72,11 +72,11 @@ jobs: os: macOS-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 if: "!endsWith(matrix.python-version, '-dev')" with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 592d5908f9..3a05759fa9 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -37,11 +37,11 @@ jobs: domain: zh.wikipedia.beta.wmcloud.org steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 if: "!endsWith(matrix.python-version, '-dev')" with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 85dae081ad..0c87bca585 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -35,7 +35,7 @@ jobs: steps: - name: set up python ${{ matrix.python-version }} if: "!endsWith(matrix.python-version, '-dev')" - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: set up development python ${{ matrix.python-version }} @@ -44,7 +44,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: true - name: run pre-commit diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index db37b206fa..9635b67845 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -67,11 +67,11 @@ jobs: os: ubuntu-22.04 steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 if: "!endsWith(matrix.python-version, '-dev')" with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/sysop_write_tests-ci.yml b/.github/workflows/sysop_write_tests-ci.yml index b7a1b7df16..8993ab60e2 100644 --- a/.github/workflows/sysop_write_tests-ci.yml +++ b/.github/workflows/sysop_write_tests-ci.yml @@ -27,11 +27,11 @@ jobs: attr: [write and not rights, write and rights, rights and not write] steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/windows_tests.yml b/.github/workflows/windows_tests.yml index 37ffa79c53..9c54f80e61 100644 --- a/.github/workflows/windows_tests.yml +++ b/.github/workflows/windows_tests.yml @@ -27,11 +27,11 @@ jobs: site: ['wikipedia:en'] steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.python-arch }} From c6d45301448f978f241fb9b2c92337fda4c949d9 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 24 Oct 2025 14:04:22 +0200 Subject: [PATCH 256/279] coverage: Exclude unintentional fails from coverage in tests_tests.py Change-Id: Ib7d8fcc99630fce9250794e0d485087fe0d26359 --- tests/tests_tests.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/tests_tests.py b/tests/tests_tests.py index 08739ee091..fa6ab1e4db 100755 --- a/tests/tests_tests.py +++ b/tests/tests_tests.py @@ -23,7 +23,7 @@ class HttpServerProblemTestCase(TestCase): } } - def test_502(self) -> None: + def test_502(self) -> None: # pragma: no cover """Test that framework is skipping this test due to HTTP status 502.""" self.fail("The test framework should skip this test but it hasn't.") @@ -51,7 +51,7 @@ def test_assert_is_empty(self) -> None: def test_assert_is_empty_fail(self) -> None: """Test assertIsEmpty method failing.""" self.assertIsEmpty(self.seq1) - self.assertIsEmpty(self.seq2) + self.assertIsEmpty(self.seq2) # pragma: no cover def test_assert_is_not_empty(self) -> None: """Test assertIsNotEmpty method.""" @@ -62,7 +62,7 @@ def test_assert_is_not_empty(self) -> None: def test_assert_is_not_empty_fail(self) -> None: """Test that assertIsNotEmpty method may fail.""" self.assertIsNotEmpty([]) - self.assertIsNotEmpty('') + self.assertIsNotEmpty('') # pragma: no cover def test_assert_length(self) -> None: """Test assertLength method.""" @@ -74,8 +74,10 @@ def test_assert_length(self) -> None: def test_assert_length_fail(self) -> None: """Test that assertLength method is failing.""" self.assertLength([], 1) + # no cover: start self.assertLength(self.seq1, 0) self.assertLength(None, self.seq) + # no cover: stop class TestRequireVersionDry(DefaultSiteTestCase): @@ -106,7 +108,7 @@ def method_with_params(self, key) -> None: def method_failing(self) -> None: """Test method for decorator with invalid parameter.""" - self.assertTrue(False, 'should never happen') + self.assertTrue(False, 'should never happen') # pragma: no cover @require_version('>=1.31') def method_succeed(self) -> None: @@ -116,7 +118,7 @@ def method_succeed(self) -> None: @require_version('<1.31') def method_fail(self) -> None: """Test that decorator skips.""" - self.assertTrue(False, 'intentional fail for test') + self.assertTrue(False, 'intentional fail for test') # pragma: no cover def test_unsupported_methods(self) -> None: """Test require_version with unsupported methods.""" From 381c2a71bf0d02b99258e4e991dbc45dd77135a6 Mon Sep 17 00:00:00 2001 From: xqt Date: Sat, 18 Oct 2025 17:51:11 +0200 Subject: [PATCH 257/279] [Bugfix] Replace timetravel.mementoweb.org with web.archive.org mementoweb.org is not reachable. Therefore: - replace http://timetravel.mementoweb.org/timegate/ with https://web.archive.org/web/ and set it to new default time gate - update tests - update weblinkchecker.py - update documentation Bug: T400570 Bug: T407694 Change-Id: Iead2f5c5b81faa56d81986ddc6593ad2e5793344 --- docs/api_ref/pywikibot.data.rst | 2 ++ pyproject.toml | 2 +- pywikibot/data/memento.py | 44 ++++++++++++++++++++++----------- scripts/weblinkchecker.py | 13 +--------- tests/memento_tests.py | 10 +++++--- 5 files changed, 39 insertions(+), 32 deletions(-) diff --git a/docs/api_ref/pywikibot.data.rst b/docs/api_ref/pywikibot.data.rst index e6612b63d4..4a5693d7ca 100644 --- a/docs/api_ref/pywikibot.data.rst +++ b/docs/api_ref/pywikibot.data.rst @@ -23,6 +23,8 @@ .. automodule:: data.memento :synopsis: Fix ups for memento-client package version 0.6.1 +.. autodata:: data.memento.DEFAULT_TIMEGATE_BASE_URI + :mod:`data.mysql` --- Mysql Requests ==================================== diff --git a/pyproject.toml b/pyproject.toml index d37f51cb3b..9c305cd163 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -195,7 +195,7 @@ ignore_missing_imports = true [tool.rstcheck] -ignore_directives = ["automodule", "autoclass", "autofunction", "tabs"] +ignore_directives = ["automodule", "autoclass", "autodata", "autofunction", "tabs"] ignore_messages = '(Undefined substitution referenced: "(release|today|version)")' ignore_roles = ["api", "phab", "pylib", "source", "wiki"] diff --git a/pywikibot/data/memento.py b/pywikibot/data/memento.py index 4cb6cb042b..97ef58ba7b 100644 --- a/pywikibot/data/memento.py +++ b/pywikibot/data/memento.py @@ -1,6 +1,8 @@ """Fix ups for memento-client package version 0.6.1. .. versionadded:: 7.4 +.. versionchanged:: 10.7 + Set default timegate to :attr`DEFAULT_TIMEGATE_BASE_URI` .. seealso:: https://github.com/mementoweb/py-memento-client#readme """ # @@ -32,6 +34,10 @@ ) +#: Default timegate; overrides the origin library setting. +DEFAULT_TIMEGATE_BASE_URI: str = 'https://web.archive.org/web/' + + class MementoClient(OldMementoClient): """A Memento Client. @@ -41,6 +47,8 @@ class MementoClient(OldMementoClient): .. versionchanged:: 7.4 `timeout` is used in several methods. + .. versionchanged:: 10.7 + Set default timegate to :attr`DEFAULT_TIMEGATE_BASE_URI` Basic usage: @@ -50,7 +58,7 @@ class MementoClient(OldMementoClient): >>> mi['original_uri'] 'http://www.bbc.com/' >>> mi['timegate_uri'] - 'http://timetravel.mementoweb.org/timegate/http://www.bbc.com/' + 'https://web.archive.org/web/http://www.bbc.com/' >>> sorted(mi['mementos']) ['closest', 'first', 'last', 'next', 'prev'] >>> from pprint import pprint @@ -67,32 +75,38 @@ class MementoClient(OldMementoClient): 'prev': {'datetime': datetime.datetime(2009, 10, 15, 19, 7, 5), 'uri': ['http://wayback.nli.org.il:8080/20091015190705/http://www.bbc.com/']}} - The output conforms to the Memento API format explained here: - http://timetravel.mementoweb.org/guide/api/#memento-json + The output conforms to the Memento API format but its description at + http://timetravel.mementoweb.org/guide/api/#memento-json is no + longer available .. note:: The mementos result is not deterministic. It may be different for the same parameters. - By default, MementoClient uses the Memento Aggregator: - http://mementoweb.org/depot/ - It is also possible to use different TimeGate, simply initialize - with a preferred timegate base uri. Toggle check_native_timegate to - see if the original uri has its own timegate. The native timegate, - if found will be used instead of the timegate_uri preferred. If no - native timegate is found, the preferred timegate_uri will be used. + with a preferred timegate base uri. Toggle *check_native_timegate* + to see if the original uri has its own timegate. The native + timegate, if found will be used instead of the *timegate_uri* + preferred. If no native timegate is found, the preferred + *timegate_uri* will be used. :param str timegate_uri: A valid HTTP base uri for a timegate. - Must start with http(s):// and end with a /. + Must start with http(s):// and end with a /. Default is + :attr:`DEFAULT_TIMEGATE_BASE_URI` + :param bool check_native_timegate: If True, the client will first + check whether the original URI has a native TimeGate. If found, + the native TimeGate is used instead of the preferred + *timegate_uri*. If False, the preferred *timegate_uri* is always + used. Default is True. :param int max_redirects: the maximum number of redirects allowed - for all HTTP requests to be made. + for all HTTP requests to be made. Default is 30. + :param requests.Session|None session: a Session object :return: A :class:`MementoClient` obj. """ # noqa: E501, W505 def __init__(self, *args, **kwargs) -> None: """Initializer.""" - # To prevent documentation inclusion from inherited class - # because it is malformed. + if 'timegate_uri' not in kwargs and not args: + kwargs['timegate_uri'] = DEFAULT_TIMEGATE_BASE_URI super().__init__(*args, **kwargs) def get_memento_info(self, request_uri: str, @@ -326,7 +340,7 @@ def get_closest_memento_url(url: str, datetime is used if none is provided. :param timegate_uri: A valid HTTP base uri for a timegate. Must start with http(s):// and end with a /. Default value is - http://timetravel.mementoweb.org/timegate/. + :attr:`DEFAULT_TIMEGATE_BASE_URI`. :param timeout: The timeout value for the HTTP connection. If None, a default value is used in :meth:`MementoClient.request_head`. """ diff --git a/scripts/weblinkchecker.py b/scripts/weblinkchecker.py index 122e02cf37..4da1a67cfa 100755 --- a/scripts/weblinkchecker.py +++ b/scripts/weblinkchecker.py @@ -175,17 +175,6 @@ ] -def get_archive_url(url): - """Get archive URL.""" - try: - return get_closest_memento_url( - url, timegate_uri='http://web.archive.org/web/') - except Exception: - return get_closest_memento_url( - url, - timegate_uri='http://timetravel.mementoweb.org/webcite/timegate/') - - def weblinks_from_text( text, without_bracketed: bool = False, @@ -410,7 +399,7 @@ def set_dead_link(self, url, error, page, weblink_dead_days) -> None: if time_since_first_found > 60 * 60 * 24 * weblink_dead_days: # search for archived page try: - archive_url = get_archive_url(url) + archive_url = get_closest_memento_url(url) except Exception as e: pywikibot.warning( f'get_closest_memento_url({url}) failed: {e}') diff --git a/tests/memento_tests.py b/tests/memento_tests.py index 86479069bb..f5d5269c71 100755 --- a/tests/memento_tests.py +++ b/tests/memento_tests.py @@ -39,10 +39,10 @@ def _get_archive_url(self, url, date_string=None): class TestMementoArchive(MementoTestCase): - """New WebCite Memento tests.""" + """Web Archive Memento tests.""" - timegate_uri = 'http://timetravel.mementoweb.org/timegate/' - hostname = timegate_uri.replace('gate/', 'map/json/http://google.com') + timegate_uri = 'https://web.archive.org/web/' + hostname = timegate_uri def test_newest(self) -> None: """Test Archive for an old https://google.com.""" @@ -55,7 +55,7 @@ def test_newest(self) -> None: class TestMementoDefault(MementoTestCase): - """Test InternetArchive is default Memento timegate.""" + """Test Web Archive is default Memento timegate.""" timegate_uri = None net = True @@ -64,6 +64,8 @@ def test_newest(self) -> None: """Test getting memento for newest https://google.com.""" archivedversion = self._get_archive_url('https://google.com') self.assertIsNotNone(archivedversion) + from pywikibot.data.memento import DEFAULT_TIMEGATE_BASE_URI + self.assertStartsWith(archivedversion, DEFAULT_TIMEGATE_BASE_URI) def test_invalid(self) -> None: """Test getting memento for invalid URL.""" From dc9be61129496aa18bf9e4a4f1bba36b5dadf86c Mon Sep 17 00:00:00 2001 From: xqt Date: Sun, 19 Oct 2025 15:54:34 +0200 Subject: [PATCH 258/279] cleanup: deprecate old (type, value, traceback) signature in throw Deprecate old (type, value, traceback) signature in GeneratorWrapper.throw. The old behaviour is necessary for Python 3.8. Bug: T340641 Change-Id: I7c50327253ff3209d801b9be6f02082730238cfa --- pywikibot/tools/collections.py | 45 +++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/pywikibot/tools/collections.py b/pywikibot/tools/collections.py index 5ef1cb9c12..458c06b4ef 100644 --- a/pywikibot/tools/collections.py +++ b/pywikibot/tools/collections.py @@ -1,6 +1,6 @@ """Collections datatypes.""" # -# (C) Pywikibot team, 2014-2024 +# (C) Pywikibot team, 2014-2025 # # Distributed under the terms of the MIT license. # @@ -11,9 +11,16 @@ from collections.abc import Collection, Generator, Iterator, Mapping from contextlib import suppress from itertools import chain +from types import TracebackType from typing import Any, NamedTuple from pywikibot.backports import Generator as GeneratorType +from pywikibot.exceptions import ArgumentDeprecationWarning +from pywikibot.tools import ( + PYTHON_VERSION, + deprecated_args, + issue_deprecation_warning, +) __all__ = ( @@ -277,18 +284,50 @@ def send(self, value: Any) -> Any: self._started_gen = self.generator return next(self._started_gen) - def throw(self, typ: Exception, val=None, tb=None) -> None: + @deprecated_args(val='value', tb='traceback') # since 10.7.0 + def throw(self, + typ: BaseException | type[BaseException] | None = None, + value: Any = None, + traceback: TracebackType | None = None) -> None: """Raise an exception inside the wrapped generator. Refer :python:`generator.throw() ` for various parameter usage. + .. versionchanged:: 10.7 + The *val* and *tb* parameters were renamed to *value* and + *traceback*. + .. deprecated:: 10.7 + The ``(type, value, traceback)`` signature is deprecated; use + single-arg signature ``throw(value)`` instead. + :raises RuntimeError: No generator started + :raises TypeError: Invalid type for *typ* argument """ if not hasattr(self, '_started_gen'): raise RuntimeError('No generator was started') - self._started_gen.throw(typ, val, tb) + + # New-style (single exception instance) with keyword argument + if typ is None and traceback is None and isinstance(value, + BaseException): + self._started_gen.throw(value) + return + + if PYTHON_VERSION > (3, 8) and not (value is None + and traceback is None): + # Old-style (type, value, traceback) signature + issue_deprecation_warning( + 'The (type, value, traceback) signature of throw()', + 'the single-arg signature', + warning_class=ArgumentDeprecationWarning, + since='10.7.0' + ) + self._started_gen.throw(typ, value, traceback) + return + + # New-style (single exception instance) + self._started_gen.throw(typ) def restart(self) -> None: """Restart the generator.""" From af39566f0ec13c145da8f09fbc4fa3cdd1b27638 Mon Sep 17 00:00:00 2001 From: xqt Date: Tue, 14 Oct 2025 18:20:12 +0200 Subject: [PATCH 259/279] IMPR: Refactor replace_magicword in CosmeticChangesToolkit - filename must not be empty in FILE_LINK_REGEX - use a marker to exclude caption from replacements in replace_magicword - update cosmetic_changes_tests and textlib_tests Bug: T396715 Change-Id: Ib6b0229f074856532a45899a4c23723569924420 --- pywikibot/cosmetic_changes.py | 29 ++++++++++++++++++++--------- pywikibot/textlib.py | 21 ++++++++++++++------- tests/cosmetic_changes_tests.py | 18 ++++++++++-------- tests/textlib_tests.py | 3 ++- 4 files changed, 46 insertions(+), 25 deletions(-) diff --git a/pywikibot/cosmetic_changes.py b/pywikibot/cosmetic_changes.py index 467734c611..725a9240a7 100644 --- a/pywikibot/cosmetic_changes.py +++ b/pywikibot/cosmetic_changes.py @@ -59,7 +59,7 @@ import re from contextlib import suppress from enum import IntEnum -from typing import Any +from typing import Any, cast from urllib.parse import urlparse, urlunparse import pywikibot @@ -502,19 +502,30 @@ def init_cache() -> None: cache[False] = True # signal there is nothing to replace def replace_magicword(match: Match[str]) -> str: + """Replace magic words in file link params, leaving captions.""" + linktext = match.group() if cache.get(False): - return match.group() - split = match.group().split('|') - if len(split) == 1: - return match.group() + return linktext + + params = match.group(2) # includes pre-leading | + if not params: + return linktext if not cache: init_cache() - # push ']]' out and re-add below - split[-1] = split[-1][:-2] - return '{}|{}]]'.format( - split[0], '|'.join(cache.get(x.strip(), x) for x in split[1:])) + # do the magic job + marker = textlib.findmarker(params) + params = textlib.replaceExcept( + params, r'\|', marker, ['link', 'template']) + parts = params.split(marker) + replaced = '|'.join(cache.get(p.strip(), p) for p in parts) + + # extract namespace + m = cast(Match[str], + re.match(r'\[\[\s*(?P[^:]+)\s*:', linktext)) + + return f'[[{m["namespace"]}:{match["filename"]}{replaced}]]' cache: dict[bool | str, Any] = {} exceptions = ['comment', 'nowiki', 'pre', 'syntaxhighlight'] diff --git a/pywikibot/textlib.py b/pywikibot/textlib.py index da5187f3b9..97ea55a130 100644 --- a/pywikibot/textlib.py +++ b/pywikibot/textlib.py @@ -74,18 +74,25 @@ (?P{{\s*[^{\|#0-9][^{\|#]*?\s* [^{]* {{ .* }}) """, re.VERBOSE | re.DOTALL) -# The following regex supports wikilinks anywhere after the first pipe -# and correctly matches the end of the file link if the wikilink contains -# [[ or ]]. -# The namespace names must be substituted into this regex. -# e.g. FILE_LINK_REGEX % 'File' -# or FILE_LINK_REGEX % '|'.join(site.namespaces[6]) +# Regex matching file links with optional parameters. +# +# Captures the filename and parameters, including nested links +# within the parameters. The regex safely matches the closing +# brackets even if inner wikilinks contain [[ or ]]. +# The Namespace names must be substituted into the pattern, e.g.: +# FILE_LINK_REGEX % 'File' +# or: FILE_LINK_REGEX % '|'.join(site.namespaces[6]) +# +# Don't use this regex directly; use textlib.get_regexes('file', site)` +# instead. +# +# 10.7: Exclude empty filename FILE_LINK_REGEX = r""" \[\[\s* (?:%s) # namespace aliases \s*: (?=(?P - [^]|]* + [^]|]+ ))(?P=filename) ( \| diff --git a/tests/cosmetic_changes_tests.py b/tests/cosmetic_changes_tests.py index 74d8f0e8c3..bd397922dd 100755 --- a/tests/cosmetic_changes_tests.py +++ b/tests/cosmetic_changes_tests.py @@ -431,17 +431,19 @@ def test_translate_magic_words(self) -> None: self.assertEqual( '[[File:Foo.bar|250px|zentriert|Bar]]', self.cct.translateMagicWords('[[File:Foo.bar|250px|center|Bar]]')) - - @unittest.expectedFailure # T396715 - def test_translateMagicWords_fail(self) -> None: - """Test translateMagicWords method. - - The current implementation doesn't check whether the magic word - is inside a template. - """ + # test magic word inside template self.assertEqual( '[[File:Foo.bar|{{Baz|thumb|foo}}]]', self.cct.translateMagicWords('[[File:Foo.bar|{{Baz|thumb|foo}}]]')) + # test magic word inside link and template + self.assertEqual( + '[[File:ABC.jpg|123px|mini|links|[[Foo|left]] {{Bar|thumb}}]]', + self.cct.translateMagicWords( + '[[File:ABC.jpg|123px|thumb|left|[[Foo|left]] {{Bar|thumb}}]]') + ) + self.assertEqual( + '[[File:Foo.bar]]', + self.cct.translateMagicWords('[[File:Foo.bar]]')) def test_cleanUpLinks_pipes(self) -> None: """Test cleanUpLinks method.""" diff --git a/tests/textlib_tests.py b/tests/textlib_tests.py index baf0c2feb0..0bb7daa23b 100755 --- a/tests/textlib_tests.py +++ b/tests/textlib_tests.py @@ -1309,11 +1309,12 @@ def test_replace_tag_file(self) -> None: 'x', 'y', ['file'], site=self.site), '[[NonFile:y]]') + # No File if filename is missing self.assertEqual( textlib.replaceExcept( '[[File:]]', 'File:', 'NonFile:', ['file'], site=self.site), - '[[File:]]') + '[[NonFile:]]') self.assertEqual( textlib.replaceExcept( From 8c48622002bde78515049e804427e401da98697d Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Mon, 27 Oct 2025 13:34:09 +0100 Subject: [PATCH 260/279] Update git submodules * Update scripts/i18n from branch 'master' to b6298b0d0ab5d82882893eee0b369c9ee0135f18 - Localisation updates from https://translatewiki.net. Change-Id: I3ef6d39e41271d2cace5693d21bca0847539e057 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index 247bf9fc65..b6298b0d0a 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit 247bf9fc6576d2efd7bc9e4bf030544d295ab61c +Subproject commit b6298b0d0ab5d82882893eee0b369c9ee0135f18 From f8f763d5b46ecdfc3f5d62cd54169e98e7c73c20 Mon Sep 17 00:00:00 2001 From: xqt Date: Mon, 20 Oct 2025 11:00:18 +0200 Subject: [PATCH 261/279] cleanup: deprecate dysfunctional Site.alllinks method Bug: T359427 Bug: T407708 Change-Id: Icdae4838a64ccb65b2e9702c930d3a69f414fda4 --- pywikibot/site/_generators.py | 12 ++++++++- tests/api_tests.py | 16 +----------- tests/site_generators_tests.py | 48 ---------------------------------- 3 files changed, 12 insertions(+), 64 deletions(-) diff --git a/pywikibot/site/_generators.py b/pywikibot/site/_generators.py index 8bdc721c4b..4da3dcca5f 100644 --- a/pywikibot/site/_generators.py +++ b/pywikibot/site/_generators.py @@ -26,7 +26,12 @@ ) from pywikibot.site._decorators import need_right from pywikibot.site._namespace import NamespaceArgType -from pywikibot.tools import deprecate_arg, deprecated_signature, is_ip_address +from pywikibot.tools import ( + deprecate_arg, + deprecated, + deprecated_signature, + is_ip_address, +) from pywikibot.tools.itertools import filter_unique @@ -1007,6 +1012,7 @@ def _maxsize_filter(item): return apgen + @deprecated(since='10.7.0') def alllinks( self, start: str = '', @@ -1044,6 +1050,10 @@ def alllinks( The minimum read timeout value should be 60 seconds in that case. + .. deprecated:: 10.7 + This method is dysfunctional and should no longer be used. It + will probably be removed in Pywikibot 11. + .. seealso:: - :api:`Alllinks` - :meth:`pagebacklinks` diff --git a/tests/api_tests.py b/tests/api_tests.py index c1bbaba912..eddd212b7f 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -604,11 +604,6 @@ def setUp(self) -> None: 'limit': {'max': 10}, 'namespace': {'multi': True} } - self.site._paraminfo['query+alllinks'] = { - 'prefix': 'al', - 'limit': {'max': 10}, - 'namespace': {'default': 0} - } self.site._paraminfo['query+links'] = { 'prefix': 'pl', } @@ -634,22 +629,13 @@ def test_namespace_param_is_not_settable(self) -> None: def test_namespace_none(self) -> None: """Test ListGenerator set_namespace with None.""" - self.gen = api.ListGenerator(listaction='alllinks', site=self.site) + self.gen = api.ListGenerator(listaction='allpages', site=self.site) with self.assertRaisesRegex( TypeError, (r'int\(\) argument must be a string, a bytes-like object ' r"or (?:a real number|a number), not 'NoneType'")): self.gen.set_namespace(None) - def test_namespace_non_multi(self) -> None: - """Test ListGenerator set_namespace when non multi.""" - self.gen = api.ListGenerator(listaction='alllinks', site=self.site) - with self.assertRaisesRegex( - TypeError, - 'alllinks module does not support multiple namespaces'): - self.gen.set_namespace([0, 1]) - self.assertIsNone(self.gen.set_namespace(0)) - def test_namespace_multi(self) -> None: """Test ListGenerator set_namespace when multi.""" self.gen = api.ListGenerator(listaction='allpages', site=self.site) diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index 9fc4172a06..950ee1e48f 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -331,54 +331,6 @@ def test_allpages_protection(self) -> None: self.assertIn('edit', page._protection) self.assertIn('sysop', page._protection['edit']) - def test_all_links(self) -> None: - """Test the site.alllinks() method.""" - mysite = self.get_site() - fwd = list(mysite.alllinks(total=10)) - uniq = list(mysite.alllinks(total=10, unique=True)) - - with self.subTest(msg='Test that unique links are in all links'): - self.assertLessEqual(len(fwd), 10) - self.assertLessEqual(len(uniq), len(fwd)) - for link in fwd: - self.assertIsInstance(link, pywikibot.Page) - self.assertIn(link, uniq) - - with self.subTest(msg='Test with start parameter'): - for page in mysite.alllinks(start='Link', total=5): - self.assertIsInstance(page, pywikibot.Page) - self.assertEqual(page.namespace(), 0) - self.assertGreaterEqual(page.title(), 'Link') - - with self.subTest(msg='Test with prefix parameter'): - for page in mysite.alllinks(prefix='Fix', total=5): - self.assertIsInstance(page, pywikibot.Page) - self.assertEqual(page.namespace(), 0) - self.assertStartsWith(page.title(), 'Fix') - - # increase timeout due to T359427/T359425 - # ~ 47s are required on wikidata - config_timeout = pywikibot.config.socket_timeout - pywikibot.config.socket_timeout = (config_timeout[0], 60) - with self.subTest(msg='Test namespace parameter'): - for page in mysite.alllinks(namespace=1, total=5): - self.assertIsInstance(page, pywikibot.Page) - self.assertEqual(page.namespace(), 1) - pywikibot.config.socket_timeout = config_timeout - - with self.subTest(msg='Test with fromids parameter'): - for page in mysite.alllinks(start='From', namespace=4, - fromids=True, total=5): - self.assertIsInstance(page, pywikibot.Page) - self.assertGreaterEqual(page.title(with_ns=False), 'From') - self.assertHasAttr(page, '_fromid') - - with self.subTest( - msg='Test that Error is raised with unique and fromids'): - errgen = mysite.alllinks(unique=True, fromids=True) - with self.assertRaises(Error): - next(errgen) - def test_all_categories(self) -> None: """Test the site.allcategories() method.""" mysite = self.get_site() From 7b2c3f2edd6b20cbb6241b893fbb398866e486c5 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 29 Oct 2025 10:28:12 +0100 Subject: [PATCH 262/279] Tests: temporary exclude testwiki from some rc tests Also use subTests in TestRecentChanges.test_flags Bug: T408667 Change-Id: I4ebd07649629aa85f2dc20acbca790e588f4d379 --- tests/site_generators_tests.py | 56 +++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index 950ee1e48f..01d69bbd9e 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -965,28 +965,40 @@ def test_changetype(self) -> None: def test_flags(self) -> None: """Test the site.recentchanges() with boolean flags.""" mysite = self.site - for change in mysite.recentchanges(minor=True, total=5): - self.assertIsInstance(change, dict) - self.assertIn('minor', change) - for change in mysite.recentchanges(minor=False, total=5): - self.assertIsInstance(change, dict) - self.assertNotIn('minor', change) - for change in mysite.recentchanges(bot=True, total=5): - self.assertIsInstance(change, dict) - self.assertIn('bot', change) - for change in mysite.recentchanges(bot=False, total=5): - self.assertIsInstance(change, dict) - self.assertNotIn('bot', change) - for change in mysite.recentchanges(anon=True, total=5): - self.assertIsInstance(change, dict) - for change in mysite.recentchanges(anon=False, total=5): - self.assertIsInstance(change, dict) - for change in mysite.recentchanges(redirect=False, total=5): - self.assertIsInstance(change, dict) - self.assertNotIn('redirect', change) - for change in mysite.recentchanges(redirect=True, total=5): - self.assertIsInstance(change, dict) - self.assertIn('redirect', change) + with self.subTest(minor=True): + for change in mysite.recentchanges(minor=True, total=5): + self.assertIsInstance(change, dict) + self.assertIn('minor', change) + with self.subTest(minor=False): + for change in mysite.recentchanges(minor=False, total=5): + self.assertIsInstance(change, dict) + self.assertNotIn('minor', change) + with self.subTest(bot=True): + for change in mysite.recentchanges(bot=True, total=5): + self.assertIsInstance(change, dict) + self.assertIn('bot', change) + with self.subTest(bot=False): + for change in mysite.recentchanges(bot=False, total=5): + self.assertIsInstance(change, dict) + self.assertNotIn('bot', change) + with self.subTest(anon=True): + for change in mysite.recentchanges(anon=True, total=5): + self.assertIsInstance(change, dict) + with self.subTest(anon=False): + for change in mysite.recentchanges(anon=False, total=5): + self.assertIsInstance(change, dict) + with self.subTest(redirect=True): + if mysite.sitename == 'wikipedia:test': + self.skipTest('Faulty rcshow filter: T408667') + for change in mysite.recentchanges(redirect=True, total=5): + self.assertIsInstance(change, dict) + self.assertIn('redirect', change) + with self.subTest(redirect=False): + if mysite.sitename == 'wikipedia:test': + self.skipTest('Faulty rcshow filter: T408667') + for change in mysite.recentchanges(redirect=False, total=5): + self.assertIsInstance(change, dict) + self.assertNotIn('redirect', change) def test_tag_filter(self) -> None: """Test the site.recentchanges() with tag filter.""" From bd660ae5acdb4c31350cc951f4a4fcd5da53c195 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 29 Oct 2025 10:36:46 +0100 Subject: [PATCH 263/279] Tests: show username if user cannot be logged in with RequireLoginMixin class Change-Id: Ib8561322c4daf2b2adf9f58b63e206e613275417 --- tests/aspects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/aspects.py b/tests/aspects.py index c76cfca519..34a6ea1160 100644 --- a/tests/aspects.py +++ b/tests/aspects.py @@ -744,8 +744,8 @@ def setUpClass(cls) -> None: site.login() if not site.user(): - raise unittest.SkipTest( - f'{cls.__name__}: Not able to login to {site}') + raise unittest.SkipTest(f'{cls.__name__}: Not able to login ' + f'{site.username()} to {site}') def setUp(self) -> None: """Set up the test case. From 49ed55de5d94e5c98724d68ebeb09ea276d31528 Mon Sep 17 00:00:00 2001 From: Sanjai Siddharthan Date: Sat, 11 Oct 2025 23:34:59 +0530 Subject: [PATCH 264/279] Fixed the watchlist.py counting issue Bug: T382708 Change-Id: I1eb3277dfeea1de758b16b5a3e8b336028184f2c --- scripts/watchlist.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/watchlist.py b/scripts/watchlist.py index c6d1a61193..b2fb03f0a4 100755 --- a/scripts/watchlist.py +++ b/scripts/watchlist.py @@ -25,7 +25,7 @@ watchlist is retrieved in parallel tasks. """ # -# (C) Pywikibot team, 2005-2024 +# (C) Pywikibot team, 2005-2025 # # Distributed under the terms of the MIT license. # @@ -38,7 +38,12 @@ import pywikibot from pywikibot import config from pywikibot.data.api import CachedRequest -from pywikibot.exceptions import InvalidTitleError +from pywikibot.exceptions import ( + APIError, + InvalidTitleError, + NoUsernameError, + ServerError, +) from pywikibot.tools.threading import BoundedPoolExecutor @@ -72,8 +77,13 @@ def count_watchlist_all(quiet=False) -> None: futures = {executor.submit(refresh, pywikibot.Site(lang, family)) for family in config.usernames for lang in config.usernames[family]} - wl_count_all = sum(len(future.result()) - for future in as_completed(futures)) + wl_count_all = 0 + for future in as_completed(futures): + try: + watchlist_pages = future.result() + wl_count_all += len(watchlist_pages) + except (NoUsernameError, APIError, ServerError) as e: + pywikibot.error(f'Failed to retrieve watchlist: {e}') if not quiet: pywikibot.info(f'There are a total of {wl_count_all} page(s) in the' ' watchlists for all wikis.') From eb9b9433ad30f55eafdf6d52be228feb3431c2b6 Mon Sep 17 00:00:00 2001 From: tejashxv Date: Fri, 3 Oct 2025 18:20:38 +0530 Subject: [PATCH 265/279] misspelling.py: Add -page option for single misspelling Allow fixing links in pages that link to a specified misspelling page using the -page argument. Previously, the bot only handled categories or templates. Bug: T151540 Change-Id: Ia83f08577ee49d5867689ae945b3280e4ad8f7f6 --- scripts/misspelling.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/misspelling.py b/scripts/misspelling.py index e976620fc4..212a1698ed 100755 --- a/scripts/misspelling.py +++ b/scripts/misspelling.py @@ -62,11 +62,20 @@ class MisspellingRobot(BaseDisambigBot): # Optional: if there is a category, one can use the -start parameter misspelling_categories = ('Q8644265', 'Q9195708') - update_options = {'start': None} + update_options = {'start': None, 'page': None} @property def generator(self) -> Generator[pywikibot.Page]: """Generator to retrieve misspelling pages or misspelling redirects.""" + # If a single page is specified, yield that page directly + if self.opt.page: + page = pywikibot.Page(self.site, self.opt.page) + if page.exists(): + yield page + else: + pywikibot.error(f"Page '{self.opt.page}' does not exist.") + return + templates = self.misspelling_templates.get(self.site.sitename) categories = [cat for cat in (self.site.page_from_repository(item) for item in self.misspelling_categories) @@ -173,6 +182,9 @@ def main(*args: str) -> None: 'At which page do you want to start?') elif opt == 'main': options[opt] = True + elif opt == 'page': + options[opt] = value or pywikibot.input( + 'Which page do you want to process?') bot = MisspellingRobot(**options) bot.run() From 9d111650ff80fab739b605f61103764769d9eb69 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 29 Oct 2025 09:24:41 +0100 Subject: [PATCH 266/279] doc: update ROADMAP.rst, CHANGELOG.rst and AUTHORS.rst Also fix memento module's documentation. Change-Id: I8ad3c1f6bf1bd7afd28a0c217e7e8fe02e1cc634 --- AUTHORS.rst | 1 + ROADMAP.rst | 16 +++++++++++++++- pywikibot/data/memento.py | 2 +- scripts/CHANGELOG.rst | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 6fbbb27069..61bd9d25e7 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -319,6 +319,7 @@ T Tacsipacsi + Tejashxv Tgr TheRogueMule theopolisme diff --git a/ROADMAP.rst b/ROADMAP.rst index 97fec9d265..f0a7c01302 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,7 +1,15 @@ Current Release Changes ======================= -* (no changes yet) +* Deprecate dysfunctional :meth:`APISite.alllinks() + `. (:phab:`T359427`, :phab:`T407708`) +* Refactor ``replace_magicwords`` in + :meth:`cosmetic_changes.CosmeticChangesToolkit.translateMagicWords`. (:phab:`T396715`) +* Deprecate old ``(type, value, traceback)`` signature in + :meth:`tools.collections.GeneratorWrapper.throw`. (:phab:`T340641`) +* Replace default timetravel.mementoweb.org with web.archive.org in :mod:`data.memento` module. + (:phab:`T400570`, :phab:`T407694`) +* i18n updates Deprecations @@ -20,6 +28,9 @@ removed in in the third subsequent major release, remaining available for the tw Pending removal in Pywikibot 11 ------------------------------- +* 10.7.0: Dysfunctional :meth:`APISite.alllinks() + ` will be removed. + (:phab:`T359427`, :phab:`T407708`) * 10.6.0: Python 3.8 support is deprecated and will be dropped soon * 8.4.0: :attr:`data.api.QueryGenerator.continuekey` will be removed in favour of :attr:`data.api.QueryGenerator.modules` @@ -97,6 +108,9 @@ Pending removal in Pywikibot 12 Pending removal in Pywikibot 13 ------------------------------- +* 10.6.0: The old ``(type, value, traceback)`` signature in + :meth:`tools.collections.GeneratorWrapper.throw` will be removed in Pywikibot 13, or earlier if it + is dropped from a future Python release. (:phab:`T340641`) * 10.6.0: :meth:`Family.isPublic()` will be removed (:phab:`T407049`) * 10.6.0: :meth:`Family.interwiki_replacements` is deprecated; use :attr:`Family.code_aliases` instead. diff --git a/pywikibot/data/memento.py b/pywikibot/data/memento.py index 97ef58ba7b..83784cc483 100644 --- a/pywikibot/data/memento.py +++ b/pywikibot/data/memento.py @@ -2,7 +2,7 @@ .. versionadded:: 7.4 .. versionchanged:: 10.7 - Set default timegate to :attr`DEFAULT_TIMEGATE_BASE_URI` + Set default timegate to :attr:`DEFAULT_TIMEGATE_BASE_URI` .. seealso:: https://github.com/mementoweb/py-memento-client#readme """ # diff --git a/scripts/CHANGELOG.rst b/scripts/CHANGELOG.rst index b4e4da639c..7c1c04c4b3 100644 --- a/scripts/CHANGELOG.rst +++ b/scripts/CHANGELOG.rst @@ -1,6 +1,21 @@ Scripts Changelog ================= +10.7.0 +------ + +* i18n updates + +misspelling +^^^^^^^^^^^ + +* ``-page`` option was added. (:phab:`T151540`) + +watchlist +^^^^^^^^^ + +* Several exceptions are caught during watchlist count. + 10.4.0 ------ From 21106a465ad66ad8108307831ce8d4bbb2c11ac3 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 29 Oct 2025 12:50:19 +0100 Subject: [PATCH 267/279] coverage: Exclude alllinks method from coverage Change-Id: Ic34505437dc7dfb13eea690f1b9afdc5dd36503c --- pywikibot/site/_generators.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pywikibot/site/_generators.py b/pywikibot/site/_generators.py index 4da3dcca5f..dc4cf6e363 100644 --- a/pywikibot/site/_generators.py +++ b/pywikibot/site/_generators.py @@ -1072,6 +1072,7 @@ def alllinks( inappropriate type such as bool, or an iterable with more than one namespace """ + # no cover: start if unique and fromids: raise Error('alllinks: unique and fromids cannot both be True.') algen = self._generator(api.ListGenerator, type_arg='alllinks', @@ -1103,6 +1104,7 @@ def alllinks( if fromids: p._fromid = link['fromid'] # type: ignore[attr-defined] yield p + # no cover: stop def allcategories( self, From ccca8701917fb9b7881f1124eab65421bcb3f4de Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 29 Oct 2025 12:55:25 +0100 Subject: [PATCH 268/279] [Bugfix] restart generator in TestPagePreloading.test_titles() Change-Id: Ib922099a0152dd9697a8163ab8db6971400d2bd5 --- tests/site_generators_tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index 01d69bbd9e..dd4853af07 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -2022,6 +2022,7 @@ def test_titles(self) -> None: self.assertEqual(page.pageid, page._pageid) del page._pageid + links.restart() # restart generator for count, page in enumerate(mysite.preloadpages(links), start=1): self.assertIsInstance(page, pywikibot.Page) self.assertIsInstance(page.exists(), bool) From b7fe9058e32e5b2891bcc1492afc2c286e156796 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 29 Oct 2025 16:06:56 +0100 Subject: [PATCH 269/279] Tests: Solve unexpected success for TestScriptGenerator.test_misspelling Change-Id: I7921cb6ca7e59fa41ae422727d749899e68b58c9 --- tests/script_tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/script_tests.py b/tests/script_tests.py index 356c6df43e..9a37290fc4 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -429,7 +429,6 @@ class TestScriptGenerator(DefaultSiteTestCase, PwbTestCase, 'interwiki', 'listpages', 'login', - 'misspelling', 'movepages', 'pagefromfile', 'parser_function_count', From caa79c063254086386f8e51765ca159dc4080fe9 Mon Sep 17 00:00:00 2001 From: xqt Date: Wed, 29 Oct 2025 17:51:04 +0100 Subject: [PATCH 270/279] Tests: run pre-commit on Python 3.15 with experimental flag Bug: T408718 Change-Id: If6e18432cf2780057355d3e167ab889f001717e5 --- .github/workflows/pre-commit.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 0c87bca585..9372a93f92 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -18,6 +18,7 @@ env: jobs: pre-commit: runs-on: ${{ matrix.os || 'ubuntu-latest' }} + continue-on-error: ${{ matrix.experimental || false }} strategy: fail-fast: false matrix: @@ -32,6 +33,7 @@ jobs: - python-version: '3.14' os: ubuntu-latest - python-version: 3.15-dev + experimental: true steps: - name: set up python ${{ matrix.python-version }} if: "!endsWith(matrix.python-version, '-dev')" From a09522db1a4845ddef4ed2b3854ff9af11e196ee Mon Sep 17 00:00:00 2001 From: Xqt Date: Thu, 30 Oct 2025 00:01:50 +0000 Subject: [PATCH 271/279] Revert Tests: temporary exclude testwiki from some rc tests Solved upstream Bug: T408667 Change-Id: Idf1ee7f07ab23ab120fdaac2c19375305dedb863 Signed-off-by: Xqt --- tests/site_generators_tests.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index dd4853af07..a5387738a2 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -988,14 +988,10 @@ def test_flags(self) -> None: for change in mysite.recentchanges(anon=False, total=5): self.assertIsInstance(change, dict) with self.subTest(redirect=True): - if mysite.sitename == 'wikipedia:test': - self.skipTest('Faulty rcshow filter: T408667') for change in mysite.recentchanges(redirect=True, total=5): self.assertIsInstance(change, dict) self.assertIn('redirect', change) with self.subTest(redirect=False): - if mysite.sitename == 'wikipedia:test': - self.skipTest('Faulty rcshow filter: T408667') for change in mysite.recentchanges(redirect=False, total=5): self.assertIsInstance(change, dict) self.assertNotIn('redirect', change) From 1a8d1246800b7565b9bfb2b87a70ca0b1d5a28da Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 30 Oct 2025 09:44:21 +0100 Subject: [PATCH 272/279] Test: Extract error message for T408721 Change-Id: Icfbbc1b47290a3c77c896691ca90f3692c5c5371 --- pywikibot/login.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pywikibot/login.py b/pywikibot/login.py index 94650e93eb..2d9b9d92d6 100644 --- a/pywikibot/login.py +++ b/pywikibot/login.py @@ -34,6 +34,9 @@ mwoauth = e +TEST_RUNNING = os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1' + + class _PasswordFileWarning(UserWarning): """The format of password file is incorrect.""" @@ -234,9 +237,8 @@ def readPassword(self) -> None: password_path = Path(config.password_file) # ignore this check when running tests - if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '0' \ - and (not password_path.is_file(**params) - or password_path.is_symlink()): + if not TEST_RUNNING and (not password_path.is_file(**params) + or password_path.is_symlink()): raise FileNotFoundError( f'Password file {password_path.name} does not exist in ' f'{password_path.parent}' @@ -622,6 +624,8 @@ def login(self, retry: bool = False, force: bool = False) -> bool: pywikibot.error(e) if retry: return self.login(retry=True, force=force) + if TEST_RUNNING: + print('>>> login:', e) # noqa: T201 return False else: pywikibot.info(f'Logged in to {self.site} via consumer ' @@ -666,6 +670,8 @@ def identity(self) -> dict[str, Any] | None: """ if self.access_token is None: pywikibot.error('Access token not set') + if TEST_RUNNING: + print('>>> identity: Access token not set') # noqa: T201 return None consumer_token = mwoauth.ConsumerToken(*self.consumer_token) @@ -677,7 +683,11 @@ def identity(self) -> dict[str, Any] | None: leeway=30.0) except Exception as e: pywikibot.error(e) + if TEST_RUNNING: + print('<<< identity:', e) # noqa: T201 else: + if TEST_RUNNING: + print('<<< identity =', identity) # noqa: T201 return identity return None From e0942162c9096e6e94a03d2239c73d97d287bad8 Mon Sep 17 00:00:00 2001 From: Xqt Date: Thu, 30 Oct 2025 09:51:08 +0000 Subject: [PATCH 273/279] Revert "Test: Extract error message for T408721" This reverts commit 1a8d1246800b7565b9bfb2b87a70ca0b1d5a28da. Reason for revert: No longer needed Change-Id: I2e3b0ebcaf965254e486f8034629199bfa7e0db4 --- pywikibot/login.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/pywikibot/login.py b/pywikibot/login.py index 2d9b9d92d6..94650e93eb 100644 --- a/pywikibot/login.py +++ b/pywikibot/login.py @@ -34,9 +34,6 @@ mwoauth = e -TEST_RUNNING = os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1' - - class _PasswordFileWarning(UserWarning): """The format of password file is incorrect.""" @@ -237,8 +234,9 @@ def readPassword(self) -> None: password_path = Path(config.password_file) # ignore this check when running tests - if not TEST_RUNNING and (not password_path.is_file(**params) - or password_path.is_symlink()): + if os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '0' \ + and (not password_path.is_file(**params) + or password_path.is_symlink()): raise FileNotFoundError( f'Password file {password_path.name} does not exist in ' f'{password_path.parent}' @@ -624,8 +622,6 @@ def login(self, retry: bool = False, force: bool = False) -> bool: pywikibot.error(e) if retry: return self.login(retry=True, force=force) - if TEST_RUNNING: - print('>>> login:', e) # noqa: T201 return False else: pywikibot.info(f'Logged in to {self.site} via consumer ' @@ -670,8 +666,6 @@ def identity(self) -> dict[str, Any] | None: """ if self.access_token is None: pywikibot.error('Access token not set') - if TEST_RUNNING: - print('>>> identity: Access token not set') # noqa: T201 return None consumer_token = mwoauth.ConsumerToken(*self.consumer_token) @@ -683,11 +677,7 @@ def identity(self) -> dict[str, Any] | None: leeway=30.0) except Exception as e: pywikibot.error(e) - if TEST_RUNNING: - print('<<< identity:', e) # noqa: T201 else: - if TEST_RUNNING: - print('<<< identity =', identity) # noqa: T201 return identity return None From 8f6bf90c949931c08944cbc72f620c9b9b396462 Mon Sep 17 00:00:00 2001 From: Translation updater bot Date: Thu, 30 Oct 2025 13:39:26 +0100 Subject: [PATCH 274/279] Update git submodules * Update scripts/i18n from branch 'master' to 2406bf3d8489a012ea3ac14dac9ca4eb6d39b923 - Localisation updates from https://translatewiki.net. Change-Id: I00ee0896951bb1a5e1e02e265eeb0e4330ec1720 --- scripts/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n b/scripts/i18n index b6298b0d0a..2406bf3d84 160000 --- a/scripts/i18n +++ b/scripts/i18n @@ -1 +1 @@ -Subproject commit b6298b0d0ab5d82882893eee0b369c9ee0135f18 +Subproject commit 2406bf3d8489a012ea3ac14dac9ca4eb6d39b923 From 89f0c38d3cfb84291e7ae905215713dadb9a477a Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 24 Oct 2025 10:23:18 +0200 Subject: [PATCH 275/279] IMPR: Improvement for pagefromfile.NoTitleError - add optional source parameter - combine parameters to an exception string - pass exception string to super class - use exception for error message within PageFromFileReader Change-Id: I7cb676ca0005b1d65c8c55c24854f1c30f514e99 --- scripts/pagefromfile.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/pagefromfile.py b/scripts/pagefromfile.py index 29bf99a225..76bcd91536 100755 --- a/scripts/pagefromfile.py +++ b/scripts/pagefromfile.py @@ -96,9 +96,19 @@ class NoTitleError(Exception): """No title found.""" - def __init__(self, offset) -> None: - """Initializer.""" + def __init__(self, offset: int, source: str | None = None) -> None: + """Initializer. + + .. versionchanged:: 10.7 + *source* was added; a message was passed to Exception super + class. + """ self.offset = offset + self.source = source + message = f'No title found at offset {offset}' + if source: + message += f' in {source!r}' + super().__init__(message) class PageFromFileRobot(SingleSiteBot, CurrentPageBot): @@ -249,7 +259,7 @@ def generator(self) -> Generator[pywikibot.Page, None, None]: break except NoTitleError as err: - pywikibot.info('\nNo title found - skipping a page.') + pywikibot.info('\n{err} - skipping a page.') text = text[err.offset:] else: page = pywikibot.Page(self.site, title) From 931a13735469559d888688468f02aa6706b2d70f Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 31 Oct 2025 11:47:21 +0100 Subject: [PATCH 276/279] tests: temporary let beta-tests fail on github Change-Id: Id5143b8f9e0b2f735760321962c98129bf24be9c --- .github/workflows/oauth_tests-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 3a05759fa9..2f880af3e9 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -31,10 +31,12 @@ jobs: family: wpbeta code: en domain: en.wikipedia.beta.wmcloud.org + experimental: true - python-version: '3.8' family: wpbeta code: zh domain: zh.wikipedia.beta.wmcloud.org + experimental: true steps: - name: Checkout Repository uses: actions/checkout@v5 From f94dc5a2e08f27fbb4752b605b18657c4d96a782 Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 31 Oct 2025 13:55:27 +0100 Subject: [PATCH 277/279] L10N: Add support for pcmwikiquote and minwikisource to Pywikibot Bug: T408345 Bug: T408353 Change-Id: I620f2b2ff3a3c5e5f4044c723d47e30d7b5f32a6 --- pywikibot/families/wikiquote_family.py | 6 +++--- pywikibot/families/wikisource_family.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pywikibot/families/wikiquote_family.py b/pywikibot/families/wikiquote_family.py index 3e6fe6af9f..4d6cce39da 100644 --- a/pywikibot/families/wikiquote_family.py +++ b/pywikibot/families/wikiquote_family.py @@ -33,9 +33,9 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'ca', 'cs', 'cy', 'da', 'de', 'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'gl', 'gor', 'gu', 'guw', 'he', 'hi', 'hr', 'hu', 'hy', 'id', 'ig', 'is', 'it', 'ja', 'ka', 'kn', 'ko', 'ku', 'ky', 'la', 'li', - 'lt', 'ml', 'mr', 'ms', 'nl', 'nn', 'no', 'pl', 'pt', 'ro', 'ru', 'sa', - 'sah', 'sk', 'sl', 'sq', 'sr', 'su', 'sv', 'ta', 'te', 'th', 'tl', - 'tr', 'uk', 'ur', 'uz', 'vi', 'zh', + 'lt', 'ml', 'mr', 'ms', 'nl', 'nn', 'no', 'pcm', 'pl', 'pt', 'ro', + 'ru', 'sa', 'sah', 'sk', 'sl', 'sq', 'sr', 'su', 'sv', 'ta', 'te', + 'th', 'tl', 'tr', 'uk', 'ur', 'uz', 'vi', 'zh', } category_redirect_templates = { diff --git a/pywikibot/families/wikisource_family.py b/pywikibot/families/wikisource_family.py index d11963aca9..c8fcff2147 100644 --- a/pywikibot/families/wikisource_family.py +++ b/pywikibot/families/wikisource_family.py @@ -30,11 +30,11 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): 'ar', 'as', 'az', 'ban', 'bcl', 'be', 'bg', 'bn', 'br', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fo', 'fr', 'gl', 'gu', 'he', 'hi', 'hr', 'hu', 'hy', 'id', 'is', 'it', - 'ja', 'jv', 'ka', 'kn', 'ko', 'la', 'li', 'lij', 'lt', 'mad', 'mk', - 'ml', 'mr', 'ms', 'mul', 'my', 'nap', 'nl', 'no', 'or', 'pa', 'pl', - 'pms', 'pt', 'ro', 'ru', 'sa', 'sah', 'sk', 'sl', 'sr', 'su', 'sv', - 'ta', 'tcy', 'te', 'th', 'tl', 'tr', 'uk', 'vec', 'vi', 'wa', 'yi', - 'zh', 'zh-min-nan', + 'ja', 'jv', 'ka', 'kn', 'ko', 'la', 'li', 'lij', 'lt', 'mad', 'min', + 'mk', 'ml', 'mr', 'ms', 'mul', 'my', 'nap', 'nl', 'no', 'or', 'pa', + 'pl', 'pms', 'pt', 'ro', 'ru', 'sa', 'sah', 'sk', 'sl', 'sr', 'su', + 'sv', 'ta', 'tcy', 'te', 'th', 'tl', 'tr', 'uk', 'vec', 'vi', 'wa', + 'yi', 'zh', 'zh-min-nan', } # Sites we want to edit but not count as real languages From 85f881c3997b694a6f6f0b687e21db9d33ff4c74 Mon Sep 17 00:00:00 2001 From: xqt Date: Thu, 23 Oct 2025 15:04:58 +0200 Subject: [PATCH 278/279] Move Python deprecation warning from pywikibot library to pwb wrapper When Pywikibot is used as a side package, the deprecation warning is not necessary because pip already installs the correct version. Otherwise, the pwb wrapper script is the appropriate place for such a warning, as it serves as the frontend entry point of Pywikibot. Change-Id: I8818e2a0667e56622084a1f49388c278faa0d876 --- pwb.py | 18 ++++++++++++++++++ pywikibot/__init__.py | 11 +---------- tests/utils.py | 3 ++- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/pwb.py b/pwb.py index 9c4af3b36e..07debd327f 100755 --- a/pwb.py +++ b/pwb.py @@ -18,6 +18,12 @@ This version of Pywikibot only supports Python 3.8+. """ +DEPRECATED_PYTHON_MESSAGE = """ + +Python {version} will be dropped soon with Pywikibot 11. +It is recommended to use Python 3.9 or above. +See phab: T401802 for further information. +""" def python_is_supported(): @@ -25,9 +31,21 @@ def python_is_supported(): return sys.version_info[:3] >= (3, 8) +def python_is_deprecated(): + """Check that Python is deprecated.""" + return sys.version_info[:3] < (3, 9) + + if not python_is_supported(): # pragma: no cover sys.exit(VERSIONS_REQUIRED_MESSAGE.format(version=sys.version)) +if python_is_deprecated(): + import warnings + msg = DEPRECATED_PYTHON_MESSAGE.format( + version=sys.version.split(maxsplit=1)[0]) + warnings.warn(msg, FutureWarning) # adjust this line no in utils.execute() + del warnings + def main() -> None: """Entry point for :func:`tests.utils.execute_pwb`.""" diff --git a/pywikibot/__init__.py b/pywikibot/__init__.py index 4ddaf22dae..9b35fde295 100644 --- a/pywikibot/__init__.py +++ b/pywikibot/__init__.py @@ -59,7 +59,7 @@ ) from pywikibot.site import BaseSite as _BaseSite from pywikibot.time import Timestamp -from pywikibot.tools import PYTHON_VERSION, normalize_username +from pywikibot.tools import normalize_username if TYPE_CHECKING: @@ -87,15 +87,6 @@ _sites: dict[str, APISite] = {} -if PYTHON_VERSION < (3, 9): - __version = sys.version.split(maxsplit=1)[0] - warnings.warn(f""" - - Python {__version} will be dropped soon with Pywikibot 11. - It is recommended to use Python 3.9 or above. - See phab: T401802 for further information. -""", FutureWarning) # adjust warnings.warn line no in utils.execute() - @cache def _code_fam_from_url(url: str, name: str | None = None) -> tuple[str, str]: diff --git a/tests/utils.py b/tests/utils.py index 870ca14649..bba040aa44 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -476,7 +476,8 @@ def execute(command: list[str], *, data_in=None, timeout=None): :param command: executable to run and arguments to use """ if PYTHON_VERSION < (3, 9): - command.insert(1, '-W ignore::FutureWarning:pywikibot:92') + command.insert(1, '-W ignore::FutureWarning:pwb:46') + command.insert(1, '-W ignore::FutureWarning:__main__:46') env = os.environ.copy() From 549e8932eac4f28b36caedaf3fbacc6f0cf2a80d Mon Sep 17 00:00:00 2001 From: xqt Date: Fri, 31 Oct 2025 14:49:05 +0100 Subject: [PATCH 279/279] [10.7] publish Pywikibot 10.7 Change-Id: Ib7dc2a21d86f00563325e26664f779e2bf4871ea --- ROADMAP.rst | 1 + pywikibot/__metadata__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ROADMAP.rst b/ROADMAP.rst index f0a7c01302..8e546d9a7d 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,6 +1,7 @@ Current Release Changes ======================= +* Add support for pcmwikiquote and minwikisource. (:phab:`T408345`, :phab:`T408353`) * Deprecate dysfunctional :meth:`APISite.alllinks() `. (:phab:`T359427`, :phab:`T407708`) * Refactor ``replace_magicwords`` in diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index ae061724d8..53458c1208 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '10.7.0.dev0' +__version__ = '10.7.0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team'