diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 2efa48eb6f..0000000000 --- a/.coveragerc +++ /dev/null @@ -1,23 +0,0 @@ -[report] -ignore_errors = True -skip_empty = True - -exclude_lines = - # Have to re-enable the standard pragma - pragma: no cover - except ImportError - except KeyboardInterrupt - except OSError - except \w*ServerError - except SyntaxError - raise ImportError - raise NotImplementedError - raise unittest\.SkipTest - self\.skipTest - if __name__ == '__main__': - if .+PYWIKIBOT_TEST_\w+.+: - if self\.mw_version < .+: - if TYPE_CHECKING: - @(abc\.)?abstractmethod - @deprecated\([^\)]+\) - @unittest\.skip diff --git a/.github/workflows/doctest.yml b/.github/workflows/doctest.yml index 615fb557e1..e14c36b09c 100644 --- a/.github/workflows/doctest.yml +++ b/.github/workflows/doctest.yml @@ -60,6 +60,7 @@ jobs: python -m pip install --upgrade pip pip --version pip install coverage + pip install "tomli; python_version < '3.11'" pip install "importlib_metadata ; python_version < '3.8'" pip install mwparserfromhell pip install packaging diff --git a/.github/workflows/login_tests-ci.yml b/.github/workflows/login_tests-ci.yml index d4f3a06b5a..168790acaf 100644 --- a/.github/workflows/login_tests-ci.yml +++ b/.github/workflows/login_tests-ci.yml @@ -86,6 +86,7 @@ jobs: python -m pip install --upgrade pip pip --version pip install coverage + pip install "tomli; python_version < '3.11'" pip install "importlib_metadata ; python_version < '3.8'" pip install mwparserfromhell pip install packaging diff --git a/.github/workflows/oauth_tests-ci.yml b/.github/workflows/oauth_tests-ci.yml index 0a53aa95cd..2af22aa8e0 100644 --- a/.github/workflows/oauth_tests-ci.yml +++ b/.github/workflows/oauth_tests-ci.yml @@ -84,8 +84,12 @@ jobs: python -m pip install --upgrade pip pip --version pip install coverage + # tomli required for coverage due to T380697 + pip install "tomli; python_version < '3.11'" pip install "importlib_metadata ; python_version < '3.8'" pip install mwparserfromhell + # PyJWT added due to T380270 + pip install "PyJWT != 2.10.0, != 2.10.1 ; python_version > '3.8'" pip install mwoauth pip install packaging pip install requests diff --git a/.github/workflows/pywikibot-ci.yml b/.github/workflows/pywikibot-ci.yml index cc2fb97cba..7aae45c79e 100644 --- a/.github/workflows/pywikibot-ci.yml +++ b/.github/workflows/pywikibot-ci.yml @@ -89,6 +89,8 @@ jobs: run: | python -m pip install --upgrade pip pip --version + # T380732 + pip install "coverage !=7.6.2,!=7.6.3,!=7.6.4,!=7.6.5,!=7.6.6,!=7.6.7,!=7.6.8; implementation_name=='pypy' and python_version=='3.10'" if [ -f dev-requirements.txt ]; then pip install -r dev-requirements.txt; fi if [ -f requirements.txt ]; then pip install -r requirements.txt; fi pip install wikitextparser diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1199d403e7..536482a4f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,9 +29,7 @@ repos: - id: destroyed-symlinks - id: end-of-file-fixer - id: fix-byte-order-marker - - id: fix-encoding-pragma - args: - - --remove + exclude: '^tests/data/' - id: forbid-new-submodules - id: mixed-line-ending - id: pretty-format-json @@ -39,6 +37,7 @@ repos: - --autofix - --indent=4 - --no-ensure-ascii + exclude: pywikibot/scripts/i18n/pywikibot/ - id: trailing-whitespace args: - --markdown-linebreak-ext=rst @@ -50,6 +49,12 @@ repos: - id: python-check-blanket-type-ignore - id: python-check-mock-methods - id: python-use-type-annotations + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.0 + hooks: + - id: ruff + args: + - --fix - repo: https://github.com/asottile/pyupgrade rev: v3.19.0 hooks: @@ -72,6 +77,11 @@ repos: hooks: - id: isort exclude: '^pwb\.py$' + - repo: https://github.com/jshwi/docsig + rev: v0.65.0 + hooks: + - id: docsig + exclude: ^(tests|scripts) - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: @@ -80,15 +90,11 @@ repos: - --doctests additional_dependencies: # Due to incompatibilities between packages the order matters. - - darglint2 - - pydocstyle==6.3.0 # deprecated and no longer maintained - flake8-bugbear!=24.1.17 - flake8-comprehensions>=3.13.0 - - flake8-docstrings>=1.4.0 - flake8-mock-x2 - flake8-print>=5.0.0 - flake8-quotes>=3.3.2 - flake8-raise - flake8-tuple>=0.4.1 - - flake8-no-u-prefixed-strings>=0.2 - pep8-naming>=0.13.3 diff --git a/.rstcheck.cfg b/.rstcheck.cfg deleted file mode 100644 index 918cfcf59a..0000000000 --- a/.rstcheck.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[rstcheck] -ignore_directives=automodule,autoclass,autofunction,tabs -ignore_messages=(Undefined substitution referenced: "(release|today|version)") -ignore_roles=api,phab,pylib,source,wiki diff --git a/CONTENT.rst b/CONTENT.rst index 124b051d53..50936affff 100644 --- a/CONTENT.rst +++ b/CONTENT.rst @@ -10,8 +10,6 @@ The contents of the package +---------------------------+-----------------------------------------------------------+ | CONTENT.rst | This Content description file | +---------------------------+-----------------------------------------------------------+ - | dev-requirements.txt | PIP requirements file for development dependencies | - +---------------------------+-----------------------------------------------------------+ | Dockerfile | Assemble a Docker image, install all dependencies via pip | +---------------------------+-----------------------------------------------------------+ | Dockerfile-dev | Docker image including development dependencies | @@ -20,18 +18,22 @@ The contents of the package +---------------------------+-----------------------------------------------------------+ | LICENSE | Reference to the MIT license | +---------------------------+-----------------------------------------------------------+ - | make_dist.py | Script to create a Pywikibot distribution | - +---------------------------+-----------------------------------------------------------+ | MANIFEST.in | Setup file for package data | +---------------------------+-----------------------------------------------------------+ + | README.rst | Short info string used by Pywikibot Nightlies | + +---------------------------+-----------------------------------------------------------+ + | ROADMAP.rst | PyPI version roadmap file | + +---------------------------+-----------------------------------------------------------+ + | dev-requirements.txt | PIP requirements file for development dependencies | + +---------------------------+-----------------------------------------------------------+ + | make_dist.py | Script to create a Pywikibot distribution | + +---------------------------+-----------------------------------------------------------+ | pwb.py | Caller script for pwb wrapper script | +---------------------------+-----------------------------------------------------------+ - | README.rst | Short info string used by Pywikibot Nightlies | + | pyproject.toml | Configuration file used by packaging tools and tests | +---------------------------+-----------------------------------------------------------+ | requirements.txt | General PIP requirements file | +---------------------------+-----------------------------------------------------------+ - | ROADMAP.rst | PyPI version roadmap file | - +---------------------------+-----------------------------------------------------------+ | setup.py | Installer script for Pywikibot framework | +---------------------------+-----------------------------------------------------------+ | tox.ini | Tests config file | @@ -44,6 +46,8 @@ The contents of the package +---------------------------+-----------------------------------------------------------+ | Directories | +===========================+===========================================================+ + | docs | Documentation files | + +---------------------------+-----------------------------------------------------------+ | pywikibot | Contains some libraries and control files | +---------------------------+-----------------------------------------------------------+ | scripts | Contains all bots and utility scripts | diff --git a/HISTORY.rst b/HISTORY.rst index 8278f995c4..eafd659af3 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,21 @@ Release History =============== +9.5.0 +----- +*30 October 2024* + +* Add support for tcywikisource and tcywiktionary (:phab:`T378473`, :phab:`T378465`) +* i18n-updates +* Update invisible chars in :mod:`tools.chars` from unicode 16.0.0 +* Rename :meth:`DataSite.getPropertyType()` + to :meth:`DataSite.get_property_type()` +* provide XXXI with :func:`date.romanNumToInt` and :func:`date.intToRomanNum` functions +* No longer raise :exc:`exceptions.UnsupportedPageError` within :meth:`data.api.PageGenerator.result` (:phab:`T377651`) +* Extract messages with strong tag from xtools as error message in + :meth:`Page.authorship()` (:phab:`T376815`) + + 9.4.1 ----- *15 October 2024* @@ -652,7 +667,7 @@ Release History * Raise InvalidTitleError instead of unspecific ValueError in ProofreadPage (:phab:`T308016`) * Preload pages if GeneratorFactory.articlenotfilter_list is not empty; also set attribute ``is_preloading``. * ClaimCollection.toJSON() should not ignore new claim (:phab:`T308245`) -* use linktrail via siteinfo and remove `update_linkrtrails` maintenance script +* use linktrail via siteinfo and remove `update_linktrails` maintenance script * Print counter statistic for all counters (:phab:`T307834`) * Use proofreadpagesinindex query module * Prioritize -namespaces options in `pagegenerators.handle_args` (:phab:`T222519`) diff --git a/ROADMAP.rst b/ROADMAP.rst index cd3def1497..b706a44562 100644 --- a/ROADMAP.rst +++ b/ROADMAP.rst @@ -1,19 +1,31 @@ Current Release Changes ======================= -* Add support for tcywikisource and tcywiktionary (:phab:`T378473`, :phab:`T378465`) +* Add support for idwikivoyage (:phab:`T381082`) +* Add docstrings of :class:`tools.classproperty` methods (:phab:`T380628`) +* Site property :attr:`BaseSite.codes` was added (:phab:`T380606`) +* Increase *leeway* parameter of :meth:`login.OauthLoginManager.identity` (:phab:`T380270`) +* Show a warning if *ignore_extension* parameter of :class:`pywikibot.FilePage` was set and the extension is invalid +* Remove old code of Python 3.2 or older in :func:`tools.chars.replace_invisible` due to :pep:`393` +* use :meth:`BasePage.autoFormat()` instead of :func:`date.getAutoFormat` in + :mod:`titletranslate` +* Upcast :class:`pywikibot.Page` to :class:`pywikibot.FilePage` in :meth:`PageGenerator.result() + ` if ``imageinfo`` is given (:phab:`T379513`) +* Update oauth requirements * i18n-updates -* Update invisible chars in :mod:`tools.chars` from unicode 16.0.0 -* Rename :meth:`DataSite.getPropertyType()` - to :meth:`DataSite.get_property_type()` -* provide XXXI with :func:`date.romanNumToInt` and :func:`date.intToRomanNum` functions -* No longer raise :exc:`exceptions.UnsupportedPageError` within :meth:`data.api.PageGenerator.result` (:phab:`T377651`) -* Extract messages with strong tag from xtools as error message in - :meth:`Page.authorship()` (:phab:`T376815`) +* Implement param *with_sort_key* in :meth:`page.BasePage.categories` (:phab:`T75561`) +* Python 3.7 support will be discontinued and probably this is the last version supporting it +* Add :meth:`page.BasePage.get_revision` method +* Retry :meth:`data.sparql.SparqlQuery.query` on internal server error (500) (:phab:`T378788`) +* Extract :meth:`APISite.linktrail()` + for hr-wiki (:phab:`T378787`) + Current Deprecations ==================== +* 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.4.0: :mod:`flow` support is deprecated and will be removed (:phab:`T371180`) @@ -71,6 +83,7 @@ Current Deprecations Pending removal in Pywikibot 10 ------------------------------- +* 9.6.0: Python 3.7 support is deprecated and will be dropped with Pywikibot 10 * 9.1.0: :func:`version.svn_rev_info` and :func:`version.getversion_svn` will be removed. SVN is no longer supported. (:phab:`T362484`) * 7.7.0: :mod:`tools.threading` classes should no longer imported from :mod:`tools` @@ -81,7 +94,7 @@ Pending removal in Pywikibot 10 * 7.3.0: Old color escape sequences like ``\03{color}`` is deprecated in favour of new color format like <> * 7.3.0: ``linktrail`` method of :class:`family.Family` is deprecated; use :meth:`APISite.linktrail() ` instead -* 7.2.0: Positional arguments *decoder*, *layer* and *newline* for :mod:`logging` functions where dropped; keyword +* 7.2.0: Positional arguments *decoder*, *layer* and *newline* for :mod:`logging` functions were dropped; keyword arguments must be used instead. * 7.2.0: ``tb`` parameter of :func:`exception()` function was renamed to ``exc_info`` * 7.2.0: XMLDumpOldPageGenerator is deprecated in favour of a ``content`` parameter of diff --git a/dev-requirements.txt b/dev-requirements.txt index a87313f96d..60cc9e917e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,6 +10,9 @@ pytest-xvfb>=3.0.0 pre-commit coverage>=5.2.1 +# required for coverage (T380697) +tomli>=2.0.1; python_version < "3.11" + # optional but needed for tests fake-useragent diff --git a/docs/api_ref/family.rst b/docs/api_ref/family.rst index 9717a19f8d..314d96a6f7 100644 --- a/docs/api_ref/family.rst +++ b/docs/api_ref/family.rst @@ -4,6 +4,7 @@ .. automodule:: family :synopsis: Objects representing MediaWiki families + :exclude-members: Family .. autoclass:: Family diff --git a/docs/api_ref/pywikibot.site.rst b/docs/api_ref/pywikibot.site.rst index 347430b6f8..1d8b3a2d6b 100644 --- a/docs/api_ref/pywikibot.site.rst +++ b/docs/api_ref/pywikibot.site.rst @@ -27,7 +27,7 @@ .. seealso:: :meth:`family.Family.linktrail` .. deprecated:: 7.3 Only supported as :class:`APISite` - method. Use :meth:`APISite.linktrail + method. Use :meth:`APISite.linktrail() ` :rtype: str diff --git a/docs/conf.py b/docs/conf.py index 522a1b273c..da75c999ed 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -579,35 +579,10 @@ def pywikibot_script_docstring_fixups(app, what, name, obj, options, lines): length = 0 -def pywikibot_family_classproperty_getattr(obj, name, *defargs): - """Custom getattr() to get classproperty instances.""" - from sphinx.util.inspect import safe_getattr - - from pywikibot.family import Family - from pywikibot.tools import classproperty - - if not isinstance(obj, type) or not issubclass(obj, Family): - return safe_getattr(obj, name, *defargs) - - for base_class in obj.__mro__: - try: - prop = base_class.__dict__[name] - except KeyError: - continue - - if not isinstance(prop, classproperty): - return safe_getattr(obj, name, *defargs) - - return prop - - return safe_getattr(obj, name, *defargs) - - def setup(app): """Implicit Sphinx extension hook.""" app.connect('autodoc-process-docstring', pywikibot_docstring_fixups) app.connect('autodoc-process-docstring', pywikibot_script_docstring_fixups) - app.add_autodoc_attrgetter(type, pywikibot_family_classproperty_getattr) autoclass_content = 'both' diff --git a/docs/index.rst b/docs/index.rst index 7929f582d9..ea1163c8c7 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.7 or higher is currently required to run the bot. +Python 3.7 or higher is currently required to run the bot but Python 3.8 or +higher is recommended. Python 3.7 support will be dropped with Pywikibot 10 soon. Pywikibot and this documentation are licensed under the :ref:`MIT license`; diff --git a/docs/requirements.txt b/docs/requirements.txt index ed1375865f..0dbffe905c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,5 +6,5 @@ rstcheck >=6.2.4 sphinxext-opengraph >= 0.9.1 sphinx-copybutton >= 0.5.2 sphinx-tabs >= 3.4.7 -tomli >= 2.02; python_version < '3.11' +tomli >= 2.0.1; python_version < '3.11' furo >= 2024.8.6 diff --git a/docs/scripts/archive.rst b/docs/scripts/archive.rst index 577986095b..5630b3a564 100644 --- a/docs/scripts/archive.rst +++ b/docs/scripts/archive.rst @@ -3,12 +3,13 @@ Outdated core scripts ********************* This list contains outdated scripts from :term:`core` banch which -aren't supported any longer. They are either archived or deleted. +aren't supported any longer. They aredeleted from repository. -Feel free to reactivate any script at any time by creating a Phabricator -task: :phab:`Recovery request -` +.. hint:: + Feel free to reactivate any script at any time by creating a + Phabricator task: :phab:`Recovery request + ` .. seealso:: :ref:`Outdated compat scripts` diff --git a/pyproject.toml b/pyproject.toml index 24e9b904ff..df9b092db9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,49 @@ Changelog = "https://doc.wikimedia.org/pywikibot/master/changelog.html" Tracker = "https://phabricator.wikimedia.org/tag/pywikibot/" +[tool.coverage.report] +ignore_errors = true +skip_empty = true + +exclude_lines = [ + # Have to re-enable the standard pragma + "pragma: no cover", + "except ImportError", + "except KeyboardInterrupt", + "except OSError", + "except \\w*ServerError", + "except SyntaxError", + "raise ImportError", + "raise NotImplementedError", + "raise unittest\\.SkipTest", + "self\\.skipTest", + "if __name__ == '__main__':", + "if .+PYWIKIBOT_TEST_\\w+.+:", + "if self\\.mw_version < .+:", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", + "@deprecated\\([^\\)]+\\)", + "@unittest\\.skip", +] + + +[tool.docsig] +disable = [ + "SIG101", + "SIG202", + "SIG203", + "SIG301", + "SIG302", + "SIG401", + "SIG402", + "SIG404", + "SIG501", + "SIG502", + "SIG503", + "SIG505", +] + + [tool.isort] py_version = 37 add_imports = ["from __future__ import annotations"] @@ -145,3 +188,19 @@ enable_error_code = [ "ignore-without-code", ] ignore_missing_imports = true + + +[tool.rstcheck] +ignore_directives = ["automodule", "autoclass", "autofunction", "tabs"] +ignore_messages = '(Undefined substitution referenced: "(release|today|version)")' +ignore_roles = ["api", "phab", "pylib", "source", "wiki"] + + +[tool.ruff.lint] +select = ["D"] +ignore = ["D105", "D211", "D213", "D214", "D401", "D404", "D406", "D407", "D412", "D413", "D416", "D417"] + +[tool.ruff.lint.per-file-ignores] +"pywikibot/families/*" = ["D102"] +"scripts/dataextend.py" = ["D101", "D102"] +"tests/ui_tests.py" = ["D102", "D103"] diff --git a/pywikibot/__init__.py b/pywikibot/__init__.py index 2889125969..6819a7f905 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, 8): + __version = sys.version.split(maxsplit=1)[0] + warn(f""" + + Python {__version} will be dropped soon with Pywikibot 10. + It is recommended to use Python 3.8 or above. + See phab: T379227 for further information. +""", FutureWarning) # adjust this line no in utils.execute() + @cache def _code_fam_from_url(url: str, name: str | None = None @@ -244,8 +253,8 @@ def Site(code: str | None = None, # noqa: N802 debug(f"Instantiated {interface.__name__} object '{_sites[key]}'") if _sites[key].code != code: - warn('Site {} instantiated using different code "{}"' - .format(_sites[key], code), UserWarning, 2) + warn(f'Site {_sites[key]} instantiated using different code ' + f'"{code}"', UserWarning, 2) return _sites[key] @@ -272,8 +281,7 @@ def Site(code: str | None = None, # noqa: N802 def showDiff(oldtext: str, # noqa: N802 newtext: str, context: int = 0) -> None: - """ - Output a string showing the differences between oldtext and newtext. + """Output a string showing the differences between oldtext and newtext. The differences are highlighted (only on compatible systems) to show which changes were made. @@ -338,9 +346,8 @@ def remaining() -> tuple[int, datetime.timedelta]: num, sec = remaining() if num > 0 and sec.total_seconds() > _config.noisysleep: - output('<>Waiting for {num} pages to be put. ' - 'Estimated time remaining: {sec}<>' - .format(num=num, sec=sec)) + output(f'<>Waiting for {num} pages to be put. ' + f'Estimated time remaining: {sec}<>') exit_queue = None if _putthread is not threading.current_thread(): diff --git a/pywikibot/__metadata__.py b/pywikibot/__metadata__.py index d067c1c401..73d3b8f068 100644 --- a/pywikibot/__metadata__.py +++ b/pywikibot/__metadata__.py @@ -12,6 +12,6 @@ from time import strftime -__version__ = '9.5.0' +__version__ = '9.6.0' __url__ = 'https://www.mediawiki.org/wiki/Manual:Pywikibot' __copyright__ = f'2003-{strftime("%Y")}, Pywikibot team' diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py index 66374df215..19317be18e 100644 --- a/pywikibot/_wbtypes.py +++ b/pywikibot/_wbtypes.py @@ -101,8 +101,7 @@ def __init__(self, lat: float, lon: float, alt: float | None = None, site: DataSite | None = None, globe_item: ItemPageStrNoneType = None, primary: bool = False) -> None: - """ - Represent a geo coordinate. + """Represent a geo coordinate. :param lat: Latitude :param lon: Longitude @@ -150,8 +149,7 @@ def entity(self) -> str: return self._entity def toWikibase(self) -> dict[str, Any]: - """ - Export the data to a JSON object for the Wikibase API. + """Export the data to a JSON object for the Wikibase API. FIXME: Should this be in the DataSite object? @@ -167,8 +165,7 @@ def toWikibase(self) -> dict[str, Any]: @classmethod def fromWikibase(cls, data: dict[str, Any], site: DataSite | None = None) -> Coordinate: - """ - Constructor to create an object from Wikibase's JSON output. + """Constructor to create an object from Wikibase's JSON output. :param data: Wikibase JSON :param site: The Wikibase site @@ -230,8 +227,7 @@ def precision(self, value: float) -> None: self._precision = value def precisionToDim(self) -> int | None: - """ - Convert precision from Wikibase to GeoData's dim and return the latter. + """Convert precision from Wikibase to GeoData's dim. dim is calculated if the Coordinate doesn't have a dimension, and precision is set. When neither dim nor precision are set, ValueError @@ -273,8 +269,7 @@ def precisionToDim(self) -> int | None: def get_globe_item(self, repo: DataSite | None = None, lazy_load: bool = False) -> pywikibot.ItemPage: - """ - Return the ItemPage corresponding to the globe. + """Return the ItemPage corresponding to the globe. Note that the globe need not be in the same data repository as the Coordinate itself. @@ -720,8 +715,7 @@ def toTimestr(self, force_iso: bool = False) -> str: self.hour, self.minute, self.second) def toTimestamp(self, timezone_aware: bool = False) -> Timestamp: - """ - Convert the data to a pywikibot.Timestamp. + """Convert the data to a pywikibot.Timestamp. .. versionchanged:: 8.0.1 *timezone_aware* parameter was added. @@ -764,8 +758,7 @@ def toWikibase(self) -> dict[str, Any]: @classmethod def fromWikibase(cls, data: dict[str, Any], site: DataSite | None = None) -> WbTime: - """ - Create a WbTime from the JSON data given by the Wikibase API. + """Create a WbTime from the JSON data given by the Wikibase API. :param data: Wikibase JSON :param site: The Wikibase site. If not provided, retrieves the data @@ -784,8 +777,7 @@ class WbQuantity(WbRepresentation): @staticmethod def _require_errors(site: DataSite | None) -> bool: - """ - Check if Wikibase site is so old it requires error bounds to be given. + """Check if Wikibase site is old and requires error bounds to be given. If no site item is supplied it raises a warning and returns True. @@ -800,8 +792,7 @@ def _require_errors(site: DataSite | None) -> bool: @staticmethod def _todecimal(value: ToDecimalType) -> Decimal | None: - """ - Convert a string to a Decimal for use in WbQuantity. + """Convert a string to a Decimal for use in WbQuantity. None value is returned as is. @@ -815,8 +806,7 @@ def _todecimal(value: ToDecimalType) -> Decimal | None: @staticmethod def _fromdecimal(value: Decimal | None) -> str | None: - """ - Convert a Decimal to a string representation suitable for WikiBase. + """Convert a Decimal to a string representation suitable for WikiBase. None value is returned as is. @@ -830,8 +820,7 @@ def __init__( error: ToDecimalType | tuple[ToDecimalType, ToDecimalType] = None, site: DataSite | None = None, ) -> None: - """ - Create a new WbQuantity object. + """Create a new WbQuantity object. :param amount: number representing this quantity :param unit: the Wikibase item for the unit or the entity URI of this @@ -878,8 +867,7 @@ def unit(self) -> str: def get_unit_item(self, repo: DataSite | None = None, lazy_load: bool = False) -> pywikibot.ItemPage: - """ - Return the ItemPage corresponding to the unit. + """Return the ItemPage corresponding to the unit. Note that the unit need not be in the same data repository as the WbQuantity itself. @@ -901,8 +889,7 @@ def get_unit_item(self, repo: DataSite | None = None, return self._unit def toWikibase(self) -> dict[str, Any]: - """ - Convert the data to a JSON object for the Wikibase API. + """Convert the data to a JSON object for the Wikibase API. :return: Wikibase JSON """ @@ -916,8 +903,7 @@ def toWikibase(self) -> dict[str, Any]: @classmethod def fromWikibase(cls, data: dict[str, Any], site: DataSite | None = None) -> WbQuantity: - """ - Create a WbQuantity from the JSON data given by the Wikibase API. + """Create a WbQuantity from the JSON data given by the Wikibase API. :param data: Wikibase JSON :param site: The Wikibase site @@ -934,13 +920,13 @@ def fromWikibase(cls, data: dict[str, Any], class WbMonolingualText(WbRepresentation): + """A Wikibase monolingual text representation.""" _items = ('text', 'language') def __init__(self, text: str, language: str) -> None: - """ - Create a new WbMonolingualText object. + """Create a new WbMonolingualText object. :param text: text string :param language: language code of the string @@ -951,8 +937,7 @@ def __init__(self, text: str, language: str) -> None: self.language = language def toWikibase(self) -> dict[str, Any]: - """ - Convert the data to a JSON object for the Wikibase API. + """Convert the data to a JSON object for the Wikibase API. :return: Wikibase JSON """ @@ -964,8 +949,7 @@ def toWikibase(self) -> dict[str, Any]: @classmethod def fromWikibase(cls, data: dict[str, Any], site: DataSite | None = None) -> WbMonolingualText: - """ - Create a WbMonolingualText from the JSON data given by Wikibase API. + """Create a WbMonolingualText from the JSON data given by Wikibase API. :param data: Wikibase JSON :param site: The Wikibase site @@ -974,6 +958,7 @@ def fromWikibase(cls, data: dict[str, Any], class WbDataPage(WbRepresentation): + """An abstract Wikibase representation for data pages. .. warning:: Perhaps a temporary implementation until :phab:`T162336` @@ -986,8 +971,7 @@ class WbDataPage(WbRepresentation): @classmethod @abc.abstractmethod def _get_data_site(cls, repo_site: DataSite) -> APISite: - """ - Return the site serving as a repository for a given data type. + """Return the site serving as a repository for a given data type. .. note:: implemented in the extended class. @@ -998,8 +982,7 @@ def _get_data_site(cls, repo_site: DataSite) -> APISite: @classmethod @abc.abstractmethod def _get_type_specifics(cls, site: DataSite) -> dict[str, Any]: - """ - Return the specifics for a given data type. + """Return the specifics for a given data type. .. note:: Must be implemented in the extended class. @@ -1017,8 +1000,7 @@ def _get_type_specifics(cls, site: DataSite) -> dict[str, Any]: @staticmethod def _validate(page: pywikibot.Page, data_site: BaseSite, ending: str, label: str) -> None: - """ - Validate the provided page against general and type specific rules. + """Validate the provided page against general and type specific rules. :param page: Page containing the data. :param data_site: The site serving as a repository for the given @@ -1050,9 +1032,8 @@ def _validate(page: pywikibot.Page, data_site: BaseSite, ending: str, # check should be enough. if not page.title().startswith('Data:') \ or not page.title().endswith(ending): - raise ValueError( - "Page must be in 'Data:' namespace and end in '{}' " - 'for {}.'.format(ending, label)) + raise ValueError(f"Page must be in 'Data:' namespace and end in " + f"'{ending}' for {label}.") def __init__(self, page: pywikibot.Page, @@ -1073,8 +1054,7 @@ def __hash__(self) -> int: return hash(self.toWikibase()) def toWikibase(self) -> str: - """ - Convert the data to the value required by the Wikibase API. + """Convert the data to the value required by the Wikibase API. :return: title of the data page incl. namespace """ @@ -1097,12 +1077,12 @@ def fromWikibase(cls, page_name: str, site: DataSite | None) -> WbDataPage: class WbGeoShape(WbDataPage): + """A Wikibase geo-shape representation.""" @classmethod def _get_data_site(cls, site: DataSite) -> APISite: - """ - Return the site serving as a geo-shape repository. + """Return the site serving as a geo-shape repository. :param site: The Wikibase site """ @@ -1110,8 +1090,7 @@ def _get_data_site(cls, site: DataSite) -> APISite: @classmethod def _get_type_specifics(cls, site: DataSite) -> dict[str, Any]: - """ - Return the specifics for WbGeoShape. + """Return the specifics for WbGeoShape. :param site: The Wikibase site """ @@ -1124,12 +1103,12 @@ def _get_type_specifics(cls, site: DataSite) -> dict[str, Any]: class WbTabularData(WbDataPage): + """A Wikibase tabular-data representation.""" @classmethod def _get_data_site(cls, site: DataSite) -> APISite: - """ - Return the site serving as a tabular-data repository. + """Return the site serving as a tabular-data repository. :param site: The Wikibase site """ @@ -1137,8 +1116,7 @@ def _get_data_site(cls, site: DataSite) -> APISite: @classmethod def _get_type_specifics(cls, site: DataSite) -> dict[str, Any]: - """ - Return the specifics for WbTabularData. + """Return the specifics for WbTabularData. :param site: The Wikibase site """ @@ -1151,6 +1129,7 @@ def _get_type_specifics(cls, site: DataSite) -> dict[str, Any]: class WbUnknown(WbRepresentation): + """A Wikibase representation for unknown data type. This will prevent the bot from breaking completely when a new type @@ -1166,8 +1145,7 @@ class WbUnknown(WbRepresentation): _items = ('json',) def __init__(self, json: dict[str, Any], warning: str = '') -> None: - """ - Create a new WbUnknown object. + """Create a new WbUnknown object. :param json: Wikibase JSON :param warning: a warning message which is shown once if @@ -1192,8 +1170,7 @@ def toWikibase(self) -> dict[str, Any]: @classmethod def fromWikibase(cls, data: dict[str, Any], site: DataSite | None = None) -> WbUnknown: - """ - Create a WbUnknown from the JSON data given by the Wikibase API. + """Create a WbUnknown from the JSON data given by the Wikibase API. :param data: Wikibase JSON :param site: The Wikibase site diff --git a/pywikibot/bot.py b/pywikibot/bot.py index 59780d3469..6e9efa8476 100644 --- a/pywikibot/bot.py +++ b/pywikibot/bot.py @@ -296,8 +296,8 @@ def set_interface(module_name: str) -> None: """ global ui - ui_module = __import__('pywikibot.userinterfaces.{}_interface' - .format(module_name), fromlist=['UI']) + ui_module = __import__(f'pywikibot.userinterfaces.{module_name}_interface', + fromlist=['UI']) ui = ui_module.UI() assert ui is not None atexit.register(ui.flush) @@ -646,8 +646,7 @@ def input_list_choice(question: str, answers: AnswerType, default: int | str | None = None, force: bool = False) -> str: - """ - Ask the user the question and return one of the valid answers. + """Ask the user the question and return one of the valid answers. :param question: The question asked without trailing spaces. :param answers: The valid answers each containing a full length answer. @@ -674,8 +673,7 @@ def calledModuleName() -> str: def handle_args(args: Iterable[str] | None = None, do_help: bool = True) -> list[str]: - """ - Handle global command line arguments and return the rest as a list. + """Handle global command line arguments and return the rest as a list. Takes the command line arguments as strings, processes all :ref:`global parameters` such as ``-lang`` or @@ -908,8 +906,7 @@ def suggest_help(missing_parameters: Sequence[str] | None = None, missing_action: bool = False, additional_text: str = '', missing_dependencies: Sequence[str] | None = None) -> bool: - """ - Output error message to use -help with additional text before it. + """Output error message to use -help with additional text before it. :param missing_parameters: A list of parameters which are missing. :param missing_generator: Whether a generator is missing. @@ -952,8 +949,7 @@ def suggest_help(missing_parameters: Sequence[str] | None = None, def writeToCommandLogFile() -> None: - """ - Save name of the called module along with all params to logs/commands.log. + """Save name of the called module along with all params to logfile. This can be used by user later to track errors or report bugs. """ @@ -1258,8 +1254,7 @@ def user_confirm(self, question: str) -> bool: def userPut(self, page: pywikibot.page.BasePage, oldtext: str, newtext: str, **kwargs: Any) -> bool: - """ - Save a new revision of a page, with user confirmation as required. + """Save a new revision of a page, with user confirmation as required. Print differences, ask user for confirmation, and puts the page if needed. @@ -1294,8 +1289,7 @@ def userPut(self, page: pywikibot.page.BasePage, oldtext: str, def _save_page(self, page: pywikibot.page.BasePage, func: Callable[..., Any], *args: Any, **kwargs: Any) -> bool: - """ - Helper function to handle page save-related option error handling. + """Helper function to handle page save-related option error handling. .. note:: Do no use it directly. Use :meth:`userPut` instead. @@ -1333,8 +1327,8 @@ def _save_page(self, page: pywikibot.page.BasePage, pywikibot.info( f'Skipping {page.title()} because of edit conflict') elif isinstance(e, SpamblacklistError): - pywikibot.info('Cannot change {} because of blacklist ' - 'entry {}'.format(page.title(), e.url)) + pywikibot.info(f'Cannot change {page.title()} because of ' + f'blacklist entry {e.url}') elif isinstance(e, LockedPageError): pywikibot.info(f'Skipping {page.title()} (locked page)') else: @@ -1396,8 +1390,8 @@ def exit(self) -> None: for op, count in self.counter.items(): if not count or op == 'read': continue - pywikibot.info('{} operation time: {:.1f} seconds' - .format(op.capitalize(), write_seconds / count)) + pywikibot.info(f'{op.capitalize()} operation time: ' + f'{write_seconds / count:.1f} seconds') # exc_info contains exception from self.run() while terminating exc_info = sys.exc_info() @@ -1460,8 +1454,8 @@ def treat(self, page: Any) -> None: :class:`page.BasePage`. For other page types the :attr:`treat_page_type` must be set. """ - raise NotImplementedError('Method {}.treat() not implemented.' - .format(self.__class__.__name__)) + raise NotImplementedError( + f'Method {type(self).__name__}.treat() not implemented.') def setup(self) -> None: """Some initial setup before :meth:`run` operation starts. @@ -1598,8 +1592,7 @@ def run(self) -> None: # a site previously defined class Bot(BaseBot): - """ - Generic bot subclass for multiple sites. + """Generic bot subclass for multiple sites. If possible the MultipleSitesBot or SingleSiteBot classes should be used instead which specifically handle multiple or single sites. @@ -1627,8 +1620,7 @@ def site(self) -> BaseSite | None: @site.setter def site(self, site: BaseSite | None) -> None: - """ - Set the Site that the bot is using. + """Set the Site that the bot is using. When Bot.run() is managing the generator and site property, this is set each time a page is on a site different from the previous page. @@ -1682,8 +1674,7 @@ def init_page(self, item: Any) -> pywikibot.page.BasePage: class SingleSiteBot(BaseBot): - """ - A bot only working on one site and ignoring the others. + """A bot only working on one site and ignoring the others. If no site is given from the start it'll use the first page's site. Any page after the site has been defined and is not on the defined site will be @@ -1693,8 +1684,7 @@ class SingleSiteBot(BaseBot): def __init__(self, site: BaseSite | bool | None = True, **kwargs: Any) -> None: - """ - Create a SingleSiteBot instance. + """Create a SingleSiteBot instance. :param site: If True it'll be set to the configured site using pywikibot.Site. @@ -1746,8 +1736,7 @@ def skip_page(self, page: pywikibot.page.BasePage) -> bool: class MultipleSitesBot(BaseBot): - """ - A bot class working on multiple sites. + """A bot class working on multiple sites. The bot should accommodate for that case and not store site specific information on only one site. @@ -1814,8 +1803,7 @@ def set_options(self, **kwargs: Any) -> None: class CurrentPageBot(BaseBot): - """ - A bot which automatically sets 'current_page' on each treat(). + """A bot which automatically sets 'current_page' on each treat(). This class should be always used together with either the MultipleSitesBot or SingleSiteBot class as there is no site management in this class. @@ -1826,8 +1814,8 @@ class CurrentPageBot(BaseBot): def treat_page(self) -> None: """Process one page (Abstract method).""" - raise NotImplementedError('Method {}.treat_page() not implemented.' - .format(self.__class__.__name__)) + raise NotImplementedError( + f'Method {type(self).__name__}.treat_page() not implemented.') def treat(self, page: pywikibot.page.BasePage) -> None: """Set page to current page and treat that page.""" @@ -1838,8 +1826,7 @@ 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. + """Call :py:obj:`Bot.userPut` but use the current page. It compares the new_text to the current page text. @@ -1865,8 +1852,7 @@ def put_current(self, new_text: str, class AutomaticTWSummaryBot(CurrentPageBot): - """ - A class which automatically defines ``summary`` for ``put_current``. + """A class which automatically defines ``summary`` for ``put_current``. The class must defined a ``summary_key`` string which contains the i18n key for :py:obj:`i18n.twtranslate`. It can also @@ -1888,8 +1874,9 @@ def summary_parameters(self) -> dict[str, str]: def summary_parameters(self, value: dict[str, str]) -> None: """Set the i18n dictionary.""" if not isinstance(value, dict): - raise TypeError('"value" must be a dict but {} was found.' - .format(type(value).__name__)) + raise TypeError( + f'"value" must be a dict but {type(value).__name__} was found.' + ) self._summary_parameters = value @summary_parameters.deleter @@ -2002,8 +1989,7 @@ def skip_page(self, page: pywikibot.page.BasePage) -> bool: class WikidataBot(Bot, ExistingPageBot): - """ - Generic Wikidata Bot to be subclassed. + """Generic Wikidata Bot to be subclassed. Source claims (P143) can be created for specific sites @@ -2041,8 +2027,7 @@ def __init__(self, **kwargs: Any) -> None: f'{self.site} is not connected to a data repository') def cacheSources(self) -> None: - """ - Fetch the sources from the list on Wikidata. + """Fetch the sources from the list on Wikidata. It is stored internally and reused by getSource() """ @@ -2055,20 +2040,20 @@ def cacheSources(self) -> None: self.repo, family[source_lang]) def get_property_by_name(self, property_name: str) -> str: - """ - Find given property and return its ID. + """Find given property and return its ID. - Method first uses site.search() and if the property isn't found, then - asks user to provide the property ID. + Method first uses site.search() and if the property isn't found, + then asks user to provide the property ID. :param property_name: property to find """ ns = self.repo.property_namespace for page in self.repo.search(property_name, total=1, namespaces=ns): - page = pywikibot.PropertyPage(self.repo, page.title()) + prop = pywikibot.PropertyPage(self.repo, page.title()) pywikibot.info( - f'Assuming that {property_name} property is {page.id}.') - return page.id + f'Assuming that {property_name} property is {prop.id}.') + return prop.id + return pywikibot.input( f'Property {property_name} was not found. Please enter the ' f'property ID (e.g. P123) of it:').upper() @@ -2078,8 +2063,7 @@ def user_edit_entity(self, entity: pywikibot.page.WikibasePage, ignore_save_related_errors: bool | None = None, ignore_server_errors: bool | None = None, **kwargs: Any) -> bool: - """ - Edit entity with data provided, with user confirmation as required. + """Edit entity with data provided, with user confirmation as required. :param entity: page to be edited :param data: data to be saved, or None if the diff should be created @@ -2119,8 +2103,7 @@ def user_add_claim(self, item: pywikibot.page.ItemPage, claim: pywikibot.page.Claim, source: BaseSite | None = None, bot: bool = True, **kwargs: Any) -> bool: - """ - Add a claim to an item, with user confirmation as required. + """Add a claim to an item, with user confirmation as required. :param item: page to be edited :param claim: claim to be saved @@ -2149,8 +2132,7 @@ def user_add_claim(self, item: pywikibot.page.ItemPage, return self._save_page(item, item.addClaim, claim, bot=bot, **kwargs) def getSource(self, site: BaseSite) -> pywikibot.page.Claim | None: - """ - Create a Claim usable as a source for Wikibase statements. + """Create a Claim usable as a source for Wikibase statements. :param site: site that is the source of assertions. @@ -2170,8 +2152,7 @@ def user_add_claim_unless_exists( source: BaseSite | None = None, logger_callback: Callable[[str], Any] = pwb_logging.log, **kwargs: Any) -> bool: - """ - Decorator of :py:obj:`user_add_claim`. + """Decorator of :py:obj:`user_add_claim`. Before adding a new claim, it checks if we can add it, using provided filters. @@ -2261,8 +2242,7 @@ def create_item_for_page(self, page: pywikibot.page.BasePage, summary: str | None = None, **kwargs: Any ) -> pywikibot.page.ItemPage | None: - """ - Create an ItemPage with the provided page as the sitelink. + """Create an ItemPage with the provided page as the sitelink. :param page: the page for which the item will be created :param data: additional data to be included in the new item (optional). @@ -2272,8 +2252,8 @@ def create_item_for_page(self, page: pywikibot.page.BasePage, :return: pywikibot.ItemPage or None """ if not summary: - summary = 'Bot: New item with sitelink from {}'.format( - page.title(as_link=True, insite=self.repo)) + summary = ('Bot: New item with sitelink from ' + f'{page.title(as_link=True, insite=self.repo)}') if data is None: data = {} @@ -2305,32 +2285,31 @@ def treat_page(self) -> None: item = pywikibot.ItemPage.fromPage(page) except NoPageError: item = None + elif isinstance(page, pywikibot.ItemPage): + item = page + page = None else: - if isinstance(page, pywikibot.ItemPage): - item = page + # FIXME: Hack because 'is_data_repository' doesn't work if + # site is the APISite. See T85483 + assert page is not None + data_site = page.site.data_repository() + if (data_site.family == page.site.family + and data_site.code == page.site.code): + is_item = page.namespace() == data_site.item_namespace.id + else: + is_item = False + if is_item: + item = pywikibot.ItemPage(data_site, page.title()) page = None else: - # FIXME: Hack because 'is_data_repository' doesn't work if - # site is the APISite. See T85483 - assert page is not None - data_site = page.site.data_repository() - if (data_site.family == page.site.family - and data_site.code == page.site.code): - is_item = page.namespace() == data_site.item_namespace.id - else: - is_item = False - if is_item: - item = pywikibot.ItemPage(data_site, page.title()) - page = None - else: - try: - item = pywikibot.ItemPage.fromPage(page) - except NoPageError: - item = None - if self.use_from_page is False: - _error(f'{page} is not in the item namespace but must' - ' be an item.') - return + try: + item = pywikibot.ItemPage.fromPage(page) + except NoPageError: + item = None + if self.use_from_page is False: + _error(f'{page} is not in the item namespace but must' + ' be an item.') + return assert not (page is None and item is None) @@ -2345,14 +2324,12 @@ def treat_page(self) -> None: def treat_page_and_item(self, page: pywikibot.page.BasePage, item: pywikibot.page.ItemPage) -> None: - """ - Treat page together with its item (if it exists). + """Treat page together with its item (if it exists). Must be implemented in subclasses. """ - raise NotImplementedError('Method {}.treat_page_and_item() not ' - 'implemented.' - .format(self.__class__.__name__)) + raise NotImplementedError(f'Method {type(self).__name__}.' + 'treat_page_and_item() not implemented.') set_interface(config.userinterface) diff --git a/pywikibot/bot_choice.py b/pywikibot/bot_choice.py index 2548a8b3ab..9ddc71d1da 100644 --- a/pywikibot/bot_choice.py +++ b/pywikibot/bot_choice.py @@ -46,8 +46,7 @@ class Option(ABC): - """ - A basic option for input_choice. + """A basic option for input_choice. The following methods need to be implemented: @@ -97,8 +96,7 @@ def stop(self) -> bool: return self._stop def handled(self, value: str) -> Option | None: - """ - Return the Option object that applies to the given value. + """Return the Option object that applies to the given value. If this Option object doesn't know which applies it returns None. """ @@ -167,8 +165,7 @@ class StandardOption(Option): """An option with a description and shortcut and returning the shortcut.""" def __init__(self, option: str, shortcut: str, **kwargs: Any) -> None: - """ - Initializer. + """Initializer. :param option: option string :param shortcut: Shortcut of the option @@ -184,9 +181,8 @@ def format(self, default: str | None = None) -> str: if self.shortcut == default: shortcut = self.shortcut.upper() if index >= 0: - return '{}[{}]{}'.format( - self.option[:index], shortcut, - self.option[index + len(self.shortcut):]) + return (f'{self.option[:index]}[{shortcut}]' + f'{self.option[index + len(self.shortcut):]}') return f'{self.option} [{shortcut}]' def result(self, value: str) -> Any: @@ -217,8 +213,7 @@ def out(self) -> str: class NestedOption(OutputOption, StandardOption): - """ - An option containing other options. + """An option containing other options. It will return True in test if this option applies but False if a sub option applies while handle returns the sub option. @@ -355,14 +350,13 @@ def handle(self) -> Any: kwargs['label'] += '#' + self.replacer._new.section else: kwargs['label'] = self.replacer._new.anchor + elif self.replacer.current_link.anchor is None: + kwargs['label'] = self.replacer.current_groups['title'] + if self.replacer.current_groups['section']: + kwargs['label'] += '#' \ + + self.replacer.current_groups['section'] else: - if self.replacer.current_link.anchor is None: - kwargs['label'] = self.replacer.current_groups['title'] - if self.replacer.current_groups['section']: - kwargs['label'] += '#' \ - + self.replacer.current_groups['section'] - else: - kwargs['label'] = self.replacer.current_link.anchor + kwargs['label'] = self.replacer.current_link.anchor return pywikibot.Link.create_separated( self.replacer._new.canonical_title(), self.replacer._new.site, **kwargs) @@ -596,11 +590,9 @@ def out(self) -> str: """Highlighted output section of the text.""" start = max(0, self.start - self.context) end = min(len(self.text), self.end + self.context) - return '{}<<{color}>>{}<>{}'.format( - self.text[start:self.start], - self.text[self.start:self.end], - self.text[self.end:end], - color=self.color) + return (f'{self.text[start:self.start]}<<{self.color}>>' + f'{self.text[self.start:self.end]}<>' + f'{self.text[self.end:end]}') class UnhandledAnswer(Exception): # noqa: N818 @@ -779,8 +771,8 @@ def handle_link(self) -> Any: if self._new is False: question += 'be unlinked?' else: - question += 'target to <>{}<>?'.format( - self._new.canonical_title()) + question += (f'target to <>' + f'{self._new.canonical_title()}<>?') choice = pywikibot.input_choice(question, choices, default=self._default, diff --git a/pywikibot/comms/eventstreams.py b/pywikibot/comms/eventstreams.py index 5bc6530771..35c02db231 100644 --- a/pywikibot/comms/eventstreams.py +++ b/pywikibot/comms/eventstreams.py @@ -93,7 +93,7 @@ class EventStreams(GeneratorWrapper): 'server_url': 'https://www.wikidata.org', ... 'type': 'edit', - 'user': '...', + 'user': ..., 'wiki': 'wikidatawiki'} >>> del stream @@ -186,8 +186,8 @@ def url(self): :raises NotImplementedError: no stream types specified """ if self._streams is None: - raise NotImplementedError('No streams specified for class {}' - .format(self.__class__.__name__)) + raise NotImplementedError( + f'No streams specified for class {type(self).__name__}') return '{host}{path}/{streams}{since}'.format( host=self._site.eventstreams_host(), path=self._site.eventstreams_path(), @@ -195,8 +195,7 @@ def url(self): since=f'?since={self._since}' if self._since else '') def set_maximum_items(self, value: int) -> None: - """ - Set the maximum number of items to be retrieved from the stream. + """Set the maximum number of items to be retrieved from the stream. If not called, most queries will continue as long as there is more data to be retrieved from the stream. @@ -206,8 +205,8 @@ def set_maximum_items(self, value: int) -> None: """ if value is not None: self._total = int(value) - debug('{}: Set limit (maximum_items) to {}.' - .format(self.__class__.__name__, self._total)) + debug(f'{type(self).__name__}: Set limit (maximum_items) to ' + f'{self._total}.') def register_filter(self, *args, **kwargs): """Register a filter. @@ -364,8 +363,8 @@ def generator(self): else: warning(f'Unknown event {event.event} occurred.') - debug('{}: Stopped iterating due to exceeding item limit.' - .format(self.__class__.__name__)) + debug(f'{type(self).__name__}: Stopped iterating due to exceeding item' + ' limit.') del self.source diff --git a/pywikibot/comms/http.py b/pywikibot/comms/http.py index edda981c33..71345d6128 100644 --- a/pywikibot/comms/http.py +++ b/pywikibot/comms/http.py @@ -1,5 +1,4 @@ -""" -Basic HTTP access interface. +"""Basic HTTP access interface. This module handles communication between the bot and the HTTP threads. @@ -162,8 +161,7 @@ def get_value(self, key, args, kwargs): def user_agent_username(username=None): - """ - Reduce username to a representation permitted in HTTP headers. + """Reduce username to a representation permitted in HTTP headers. To achieve that, this function: 1) replaces spaces (' ') with '_' @@ -251,8 +249,7 @@ def request(site: pywikibot.site.BaseSite, uri: str | None = None, headers: dict | None = None, **kwargs) -> requests.Response: - """ - Request to Site with default error handling and response decoding. + """Request to Site with default error handling and response decoding. See :py:obj:`requests.Session.request` for additional parameters. @@ -309,8 +306,7 @@ def get_authentication(uri: str) -> tuple[str, str] | None: def error_handling_callback(response): - """ - Raise exceptions and log alerts. + """Raise exceptions and log alerts. :param response: Response returned by Session.request(). :type response: :py:obj:`requests.Response` @@ -365,8 +361,7 @@ def error_handling_callback(response): def fetch(uri: str, method: str = 'GET', headers: dict | None = None, default_error_handling: bool = True, use_fake_user_agent: bool | str = False, **kwargs): - """ - HTTP request. + """HTTP request. See :py:obj:`requests.Session.request` for parameters. @@ -405,7 +400,7 @@ def assign_fake_user_agent(use_fake_user_agent, uri): if use_fake_user_agent and isinstance(use_fake_user_agent, str): return use_fake_user_agent # Custom UA. raise ValueError('Invalid parameter: ' - 'use_fake_user_agent={}'.format(use_fake_user_agent)) + f'use_fake_user_agent={use_fake_user_agent}') def assign_user_agent(user_agent_format_string): if not user_agent_format_string or '{' in user_agent_format_string: @@ -462,7 +457,7 @@ def assign_user_agent(user_agent_format_string): # Extract charset (from content-type header) CHARSET_RE = re.compile( r'charset\s*=\s*(?P[\'"]?)(?P[^\'",;>/]+)(?P=q)', - flags=re.I, + flags=re.IGNORECASE, ) @@ -559,8 +554,9 @@ def _try_decode(content: bytes, encoding: str | None) -> str | None: if header_codecs and charset_codecs and header_codecs != charset_codecs: pywikibot.warning( - 'Encoding "{}" requested but "{}" received in the ' - 'response header.'.format(charset, header_encoding)) + f'Encoding "{charset}" requested but "{header_encoding}" received' + ' in the response header.' + ) _encoding = _try_decode(response.content, header_encoding) \ or _try_decode(response.content, charset) diff --git a/pywikibot/config.py b/pywikibot/config.py index b5c9032f5c..c2089459f4 100644 --- a/pywikibot/config.py +++ b/pywikibot/config.py @@ -1,5 +1,4 @@ -""" -Module to define and load pywikibot configuration default and user preferences. +"""Module to define pywikibot configuration default and user preferences. User preferences are loaded from a python file called `user-config.py`, which may be located in directory specified by the environment variable @@ -398,9 +397,10 @@ def exists(directory: str) -> bool: if __no_user_config is None: assert get_base_dir.__doc__ is not None exc_text += ( - '\nPlease check that {0} is stored in the correct location.' - '\nDirectory where {0} is searched is determined as follows:' - '\n\n '.format(config_file) + f'\nPlease check that {config_file} is stored in the correct' + ' location.' + f'\nDirectory where {config_file} is searched is determined as' + ' follows:\n\n ' ) + get_base_dir.__doc__ raise RuntimeError(exc_text) @@ -998,9 +998,10 @@ def _assert_types( DEPRECATED_VARIABLE = ( - '"{{}}" present in our {} is no longer a supported configuration variable ' - 'and should be removed. Please inform the maintainers if you depend on it.' - .format(user_config_file)) + f'"{{}}" present in our {user_config_file} is no longer a supported' + ' configuration variable and should be removed. Please inform the' + ' maintainers if you depend on it.' +) def _check_user_config_types( @@ -1026,10 +1027,10 @@ def _check_user_config_types( warn('\n' + fill(DEPRECATED_VARIABLE.format(name)), _ConfigurationDeprecationWarning) elif name not in _future_variables: - warn('\n' + fill('Configuration variable "{}" is defined in ' - 'your {} but unknown. It can be a misspelled ' - 'one or a variable that is no longer ' - 'supported.'.format(name, user_config_file)), + warn('\n' + fill(f'Configuration variable "{name}" is defined ' + f'in your {user_config_file} but unknown. It' + ' can be a misspelled one or a variable that' + ' is no longer supported.'), UserWarning) diff --git a/pywikibot/cosmetic_changes.py b/pywikibot/cosmetic_changes.py index 8df7cd4322..76fdeefc83 100644 --- a/pywikibot/cosmetic_changes.py +++ b/pywikibot/cosmetic_changes.py @@ -1,5 +1,4 @@ -""" -This module can do slight modifications to tidy a wiki page's source code. +"""This module can do slight modifications to tidy a wiki page's source code. The changes are not supposed to change the look of the rendered wiki page. @@ -319,8 +318,9 @@ def change(self, text: str) -> bool | str: new_text = self._change(text) except Exception as e: if self.ignore == CANCEL.PAGE: - pywikibot.warning('Skipped "{}", because an error occurred.' - .format(self.title)) + pywikibot.warning( + f'Skipped "{self.title}", because an error occurred.' + ) pywikibot.error(e) return False raise @@ -330,14 +330,14 @@ def change(self, text: str) -> bool | str: return new_text def fixSelfInterwiki(self, text: str) -> str: - """ - Interwiki links to the site itself are displayed like local links. + """Interwiki links to the site itself are displayed like local links. Remove their language code prefix. """ if not self.talkpage and pywikibot.calledModuleName() != 'interwiki': - interwikiR = re.compile(r'\[\[(?: *:)? *{} *: *([^\[\]\n]*)\]\]' - .format(self.site.code)) + interwikiR = re.compile( + rf'\[\[(?: *:)? *{self.site.code} *: *([^\[\]\n]*)\]\]' + ) text = interwikiR.sub(r'[[\1]]', text) return text @@ -462,9 +462,8 @@ def translateAndCapitalizeNamespaces(self, text: str) -> str: else 'User talk'] # lowerspaced and underscored namespaces for i, item in enumerate(namespaces): - item = item.replace(' ', '[ _]') - item = f'[{item[0]}{item[0].lower()}]' + item[1:] - namespaces[i] = item + ns = item.replace(' ', '[ _]') + namespaces[i] = f'[{ns[0]}{ns[0].lower()}]{ns[1:]}' namespaces.append(first_lower(final_ns)) if final_ns and namespaces: if (self.site.sitename == 'wikipedia:pt' @@ -529,7 +528,7 @@ def replace_magicword(match: Match[str]) -> str: exceptions = ['comment', 'nowiki', 'pre', 'syntaxhighlight'] regex = re.compile( FILE_LINK_REGEX % '|'.join(self.site.namespaces[6]), - flags=re.X) + flags=re.VERBOSE) return textlib.replaceExcept( text, regex, replace_magicword, exceptions) @@ -642,8 +641,8 @@ def handleOneLink(match: Match[str]) -> str: # instead of a pipelink elif (firstcase_label.startswith(firstcase_title) and trailR.sub('', label[len(titleWithSection):]) == ''): - newLink = '[[{}]]{}'.format(label[:len(titleWithSection)], - label[len(titleWithSection):]) + newLink = (f'[[{label[:len(titleWithSection)]}]]' + f'{label[len(titleWithSection):]}') else: # Try to capitalize the first letter of the title. @@ -729,7 +728,7 @@ def removeEmptySections(self, text: str) -> str: if self.site.code in skip_templates: for template in skip_templates[self.site.code]: skip_regexes.append( - re.compile(r'\{\{\s*%s\s*\}\}' % template, re.I)) + re.compile(r'\{\{\s*%s\s*\}\}' % template, re.IGNORECASE)) # empty lists skip_regexes.append(re.compile(r'(?m)^[\*#] *$')) @@ -767,8 +766,7 @@ def removeUselessSpaces(self, text: str) -> str: return text def removeNonBreakingSpaceBeforePercent(self, text: str) -> str: - """ - Remove a non-breaking space between number and percent sign. + """Remove a non-breaking space between number and percent sign. Newer MediaWiki versions automatically place a non-breaking space in front of a percent sign, so it is no longer required to place it @@ -779,8 +777,7 @@ def removeNonBreakingSpaceBeforePercent(self, text: str) -> str: return text def cleanUpSectionHeaders(self, text: str) -> str: - """ - Add a space between the equal signs and the section title. + """Add a space between the equal signs and the section title. Example:: @@ -805,8 +802,7 @@ def cleanUpSectionHeaders(self, text: str) -> str: ['comment', 'math', 'nowiki', 'pre']) def putSpacesInLists(self, text: str) -> str: - """ - Add a space between the * or # and the text. + """Add a space between the * or # and the text. .. note:: This space is recommended in the syntax help on the English, German and French Wikipedias. It might be that it @@ -896,9 +892,8 @@ def replace_link(match: Match[str]) -> str: # Match first a non space in the title to prevent that multiple # spaces at the end without title will be matched by it - title_regex = (r'(?P[^{sep}]+?)' - r'(\s+(?P[^\s].*?))' - .format(sep=separator)) + title_regex = (rf'(?P<link>[^{separator}]+?)' + r'(\s+(?P<title>[^\s].*?))') url_regex = fr'\[\[?{url}?\s*\]\]?' text = textlib.replaceExcept( text, @@ -1048,8 +1043,7 @@ def fixArabicLetters(self, text: str) -> str: faChrs = 'ءاآأإئؤبپتثجچحخدذرزژسشصضطظعغفقکگلمنوهیةيك' + digits['fa'] # not to let bot edits in latin content - exceptions.append(re.compile('[^{fa}] *?"*? *?, *?[^{fa}]' - .format(fa=faChrs))) + exceptions.append(re.compile(f'[^{faChrs}] *?"*? *?, *?[^{faChrs}]')) text = textlib.replaceExcept(text, ',', '،', exceptions, site=self.site) if self.site.code == 'ckb': @@ -1069,8 +1063,7 @@ def fixArabicLetters(self, text: str) -> str: return text def commonsfiledesc(self, text: str) -> str: - """ - Clean up file descriptions on Wikimedia Commons. + """Clean up file descriptions on Wikimedia Commons. It works according to [1] and works only on pages in the file namespace on Wikimedia Commons. diff --git a/pywikibot/data/api/__init__.py b/pywikibot/data/api/__init__.py index c6475673af..ca065a31ea 100644 --- a/pywikibot/data/api/__init__.py +++ b/pywikibot/data/api/__init__.py @@ -46,8 +46,7 @@ def _invalidate_superior_cookies(family) -> None: - """ - Clear cookies for site's second level domain. + """Clear cookies for site's second level domain. The http module takes care of all the cookie stuff. This is a workaround for requests bug, see :phab:`T224712` diff --git a/pywikibot/data/api/_generators.py b/pywikibot/data/api/_generators.py index c4fef30368..fdf941adfe 100644 --- a/pywikibot/data/api/_generators.py +++ b/pywikibot/data/api/_generators.py @@ -95,8 +95,7 @@ def __init__( data_name: str = 'data', **kwargs ) -> None: - """ - Initialize an APIGenerator object. + """Initialize an APIGenerator object. kwargs are used to create a Request object; see that object's documentation for values. @@ -123,8 +122,7 @@ def __init__( self.request[self.limit_name] = self.query_increment def set_query_increment(self, value: int) -> None: - """ - Set the maximum number of items to be retrieved per API query. + """Set the maximum number of items to be retrieved per API query. If not called, the default is config.step. @@ -133,12 +131,11 @@ def set_query_increment(self, value: int) -> None: """ self.query_increment = int(value) self.request[self.limit_name] = self.query_increment - pywikibot.debug('{}: Set query_increment to {}.' - .format(type(self).__name__, self.query_increment)) + pywikibot.debug(f'{type(self).__name__}: Set query_increment to ' + f'{self.query_increment}.') def set_maximum_items(self, value: int | str | None) -> None: - """ - Set the maximum number of items to be retrieved from the wiki. + """Set the maximum number of items to be retrieved from the wiki. If not called, most queries will continue as long as there is more data to be retrieved from the API. @@ -150,15 +147,14 @@ def set_maximum_items(self, value: int | str | None) -> None: self.limit = int(value) if self.query_increment and self.limit < self.query_increment: self.request[self.limit_name] = self.limit - pywikibot.debug('{}: Set request item limit to {}' - .format(type(self).__name__, self.limit)) - pywikibot.debug('{}: Set limit (maximum_items) to {}.' - .format(type(self).__name__, self.limit)) + pywikibot.debug(f'{type(self).__name__}: Set request item ' + f'limit to {self.limit}') + pywikibot.debug(f'{type(self).__name__}: Set limit ' + f'(maximum_items) to {self.limit}.') @property def generator(self): - """ - Submit request and iterate the response. + """Submit request and iterate the response. Continues response as needed until limit (if defined) is reached. @@ -180,14 +176,15 @@ def generator(self): yield item n += 1 if self.limit is not None and n >= self.limit: - pywikibot.debug('{}: Stopped iterating due to ' - 'exceeding item limit.' - .format(type(self).__name__)) + pywikibot.debug( + f'{type(self).__name__}: Stopped iterating due to' + ' exceeding item limit.' + ) return offset += n_items else: - pywikibot.debug('{}: Stopped iterating due to empty list in ' - 'response.'.format(type(self).__name__)) + pywikibot.debug(f'{type(self).__name__}: Stopped iterating' + ' due to empty list in response.') break @@ -242,8 +239,8 @@ def __init__(self, **kwargs) -> None: self.modules = parameters[modtype].split('|') break else: - raise Error('{}: No query module name found in arguments.' - .format(self.__class__.__name__)) + raise Error(f'{type(self).__name__}: No query module name found' + ' in arguments.') parameters['indexpageids'] = True # always ask for list of pageids self.continue_name = 'continue' @@ -270,10 +267,11 @@ def __init__(self, **kwargs) -> None: self.limited_module = module limited_modules.remove(module) break - pywikibot.log('{}: multiple requested query modules support limits' - "; using the first such module '{}' of {!r}" - .format(self.__class__.__name__, self.limited_module, - self.modules)) + pywikibot.log( + f'{type(self).__name__}: multiple requested query modules' + ' support limits; using the first such module ' + f"{self.limited_module}' of {self.modules!r}" + ) # Set limits for all remaining limited modules to max value. # Default values will only cause more requests and make the query @@ -383,8 +381,9 @@ def set_query_increment(self, value) -> None: self.query_limit = limit else: self.query_limit = min(self.api_limit, limit) - pywikibot.debug('{}: Set query_limit to {}.' - .format(type(self).__name__, self.query_limit)) + pywikibot.debug( + f'{type(self).__name__}: Set query_limit to {self.query_limit}.' + ) def set_maximum_items(self, value: int | str | None) -> None: """Set the maximum number of items to be retrieved from the wiki. @@ -414,8 +413,9 @@ def _update_limit(self) -> None: limit = int(param['max']) if self.api_limit is None or limit < self.api_limit: self.api_limit = limit - pywikibot.debug('{}: Set query_limit to {}.' - .format(type(self).__name__, self.api_limit)) + pywikibot.debug( + f'{type(self).__name__}: Set query_limit to {self.api_limit}.' + ) def support_namespace(self) -> bool: """Check if namespace is a supported parameter on this query. @@ -450,8 +450,8 @@ def set_namespace(self, namespaces): param = self.site._paraminfo.parameter('query+' + self.limited_module, 'namespace') if not param: - pywikibot.warning('{} module does not support a namespace ' - 'parameter'.format(self.limited_module)) + pywikibot.warning(f'{self.limited_module} module does not support' + ' a namespace parameter') warn('set_namespace() will be modified to raise TypeError ' 'when namespace parameter is not supported. ' 'It will be a Breaking Change, please update your code ' @@ -472,8 +472,8 @@ def set_namespace(self, namespaces): if 'multi' not in param and len(namespaces) != 1: if self._check_result_namespace is NotImplemented: - raise TypeError('{} module does not support multiple ' - 'namespaces'.format(self.limited_module)) + raise TypeError(f'{self.limited_module} module does not' + ' support multiple namespaces') self._namespaces = set(namespaces) namespaces = None @@ -612,9 +612,8 @@ def generator(self): self.data = self.request.submit() if not self.data or not isinstance(self.data, dict): - pywikibot.debug( - '{}: stopped iteration because no dict retrieved from api.' - .format(type(self).__name__)) + pywikibot.debug(f'{type(self).__name__}: stopped iteration' + ' because no dict retrieved from api.') break if 'query' in self.data and self.resultkey in self.data['query']: @@ -728,9 +727,12 @@ def result(self, pagedata: dict[str, Any]) -> pywikibot.Page: of object. .. versionchanged:: 9.5 - no longer raise :exc:`exceptions.UnsupportedPageError` but + No longer raise :exc:`exceptions.UnsupportedPageError` but return a generic :class:`pywikibot.Page` obect. The exception is raised when getting the content for example. + .. versionchanged:: 9.6 + Upcast to :class:`page.FilePage` if *pagedata* has + ``imageinfo`` contents even if the file extension is invalid. """ p = pywikibot.Page(self.site, pagedata['title'], pagedata['ns']) ns = pagedata['ns'] @@ -739,7 +741,8 @@ def result(self, pagedata: dict[str, Any]) -> pywikibot.Page: p = pywikibot.User(p) elif ns == Namespace.FILE: with suppress(ValueError): - p = pywikibot.FilePage(p) + p = pywikibot.FilePage( + p, ignore_extension='imageinfo' in pagedata) elif ns == Namespace.CATEGORY: p = pywikibot.Category(p) @@ -764,8 +767,7 @@ class PropertyGenerator(QueryGenerator): """ def __init__(self, prop: str, **kwargs) -> None: - """ - Initializer. + """Initializer. Required and optional parameters are as for ``Request``, except that action=query is assumed and prop is required. @@ -844,8 +846,7 @@ class ListGenerator(QueryGenerator): """ def __init__(self, listaction: str, **kwargs) -> None: - """ - Initializer. + """Initializer. Required and optional parameters are as for ``Request``, except that action=query is assumed and listaction is required. @@ -988,8 +989,7 @@ def _update_coordinates(page, coordinates) -> None: def update_page(page: pywikibot.Page, pagedict: dict[str, Any], props: Iterable[str] | None = None) -> None: - """ - Update attributes of Page object *page*, based on query data in *pagedict*. + """Update attributes of *page*, based on query data in *pagedict*. :param page: object to be updated :param pagedict: the contents of a *page* element of a query diff --git a/pywikibot/data/api/_optionset.py b/pywikibot/data/api/_optionset.py index d31f0e4ed8..75019c74e0 100644 --- a/pywikibot/data/api/_optionset.py +++ b/pywikibot/data/api/_optionset.py @@ -17,8 +17,7 @@ class OptionSet(MutableMapping): - """ - A class to store a set of options which can be either enabled or not. + """A class to store a set of options which can be either enabled or not. If it is instantiated with the associated site, module and parameter it will only allow valid names as options. If instantiated 'lazy loaded' it @@ -95,8 +94,7 @@ def _set_site(self, site, module: str, param: str, self._site_set = True def from_dict(self, dictionary): - """ - Load options from the dict. + """Load options from the dict. The options are not cleared before. If changes have been made previously, but only the dict values should be applied it needs to be @@ -153,8 +151,7 @@ def __setitem__(self, name, value): raise ValueError(f'Invalid value "{value}"') def __getitem__(self, name) -> bool | None: - """ - Return whether the option is enabled. + """Return whether the option is enabled. :return: If the name has been set it returns whether it is enabled. Otherwise it returns None. If the site has been set it raises a diff --git a/pywikibot/data/api/_paraminfo.py b/pywikibot/data/api/_paraminfo.py index b7cb846b30..b4d7788582 100644 --- a/pywikibot/data/api/_paraminfo.py +++ b/pywikibot/data/api/_paraminfo.py @@ -139,8 +139,7 @@ def fetch(self, modules: Iterable | str) -> None: self._fetch(modules) def _fetch(self, modules: set | frozenset) -> None: - """ - Fetch paraminfo for multiple modules without initializing beforehand. + """Get paraminfo for multiple modules without initializing beforehand. :param modules: API modules to load and which haven't been loaded yet. """ @@ -194,10 +193,11 @@ def module_generator(): if len(missing_modules) == 1 and len(normalized_result) == 1: # Okay it's possible to recover normalized_result = next(iter(normalized_result.values())) - pywikibot.warning('The module "{0[name]}" ("{0[path]}") ' - 'was returned as path even though "{1}" ' - 'was requested'.format(normalized_result, - missing_modules[0])) + pywikibot.warning( + f'The module "{normalized_result["name"]}" ' + f'("{normalized_result["path"]}") was returned as path ' + f'even though "{missing_modules[0]}" was requested' + ) normalized_result['path'] = missing_modules[0] normalized_result['name'] = missing_modules[0].rsplit('+')[0] normalized_result = {missing_modules[0]: normalized_result} diff --git a/pywikibot/data/api/_requests.py b/pywikibot/data/api/_requests.py index 0e84593b73..d0893f647b 100644 --- a/pywikibot/data/api/_requests.py +++ b/pywikibot/data/api/_requests.py @@ -147,8 +147,7 @@ def __init__(self, site=None, retry_wait: int | None = None, use_get: bool | None = None, parameters=_PARAM_DEFAULT, **kwargs) -> None: - """ - Create a new Request instance with the given parameters. + """Create a new Request instance with the given parameters. The parameters for the request can be defined via either the 'parameters' parameter or the keyword arguments. The keyword arguments @@ -284,8 +283,7 @@ def _warn_kwargs(cls) -> None: @classmethod def clean_kwargs(cls, kwargs: dict) -> dict: - """ - Convert keyword arguments into new parameters mode. + """Convert keyword arguments into new parameters mode. If there are no other arguments in kwargs apart from the used arguments by the class' initializer it'll just return kwargs and otherwise remove @@ -331,8 +329,7 @@ def clean_kwargs(cls, kwargs: dict) -> dict: return kwargs def _format_value(self, value): - """ - Format the MediaWiki API request parameter. + """Format the MediaWiki API request parameter. Converts from Python datatypes to MediaWiki API parameter values. @@ -404,8 +401,7 @@ def iteritems(self): return iter(self.items()) def _add_defaults(self): - """ - Add default parameters to the API request. + """Add default parameters to the API request. This method will only add them once. """ @@ -449,8 +445,7 @@ def _add_defaults(self): self.__defaulted = True # skipcq: PTC-W0037 def _encoded_items(self) -> dict[str, str | bytes]: - """ - Build a dict of params with minimal encoding needed for the site. + """Build a dict of params with minimal encoding needed for the site. This helper method only prepares params for serialisation or transmission, so it only encodes values which are not ASCII, @@ -496,8 +491,7 @@ def _encoded_items(self) -> dict[str, str | bytes]: return params def _http_param_string(self): - """ - Return the parameters as a HTTP URL query fragment. + """Return the parameters as a HTTP URL query fragment. URL encodes the parameters provided by _encoded_items() @@ -615,12 +609,11 @@ def _use_get(self): @classmethod def _build_mime_request(cls, params: dict, mime_params: dict) -> tuple[dict, bytes]: - """ - Construct a MIME multipart form post. + """Construct a MIME multipart form post. :param params: HTTP request params :param mime_params: HTTP request parts which must be sent in the body - :type mime_params: dict of (content, keytype, headers) # noqa: DAR103 + :type mime_params: dict of (content, keytype, headers) :return: HTTP request headers and body """ # construct a MIME message containing all API key/values @@ -1187,8 +1180,7 @@ def create_simple(cls, req_site, **kwargs): @classmethod def _get_cache_dir(cls) -> Path: - """ - Return the base directory path for cache entries. + """Return the base directory path for cache entries. The directory will be created if it does not already exist. diff --git a/pywikibot/data/memento.py b/pywikibot/data/memento.py index 9433e4282d..9bd5110dda 100644 --- a/pywikibot/data/memento.py +++ b/pywikibot/data/memento.py @@ -9,7 +9,7 @@ # Parts of MementoClient class codes are # licensed under the BSD open source software license. # -# (C) Pywikibot team, 2015-2023 +# (C) Pywikibot team, 2015-2024 # # Distributed under the terms of the MIT license. # @@ -179,8 +179,8 @@ def get_native_timegate_uri(self, ) except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError): # pragma: no cover - warning('Could not connect to URI {}, returning no native ' - 'URI-G'.format(original_uri)) + warning(f'Could not connect to URI {original_uri}, returning' + 'no native URI-G') return None debug('Request headers sent to search for URI-G: ' @@ -224,8 +224,7 @@ def is_memento(uri: str, response: requests.Response | None = None, session: requests.Session | None = None, timeout: int | None = None) -> bool: - """ - Determines if the URI given is indeed a Memento. + """Determines if the URI given is indeed a Memento. The simple case is to look for a Memento-Datetime header in the request, but not all archives are Memento-compliant yet. diff --git a/pywikibot/data/sparql.py b/pywikibot/data/sparql.py index 69e26e8dac..d217e8b6de 100644 --- a/pywikibot/data/sparql.py +++ b/pywikibot/data/sparql.py @@ -15,7 +15,7 @@ from pywikibot.backports import removeprefix from pywikibot.comms import http from pywikibot.data import WaitingMixin -from pywikibot.exceptions import Error, NoUsernameError +from pywikibot.exceptions import Error, NoUsernameError, ServerError try: @@ -28,6 +28,7 @@ class SparqlQuery(WaitingMixin): + """SPARQL Query class. This class allows to run SPARQL queries against any SPARQL endpoint. @@ -42,8 +43,7 @@ def __init__(self, entity_url: str | None = None, repo=None, max_retries: int | None = None, retry_wait: float | None = None) -> None: - """ - Create endpoint. + """Create endpoint. :param endpoint: SPARQL endpoint URL :param entity_url: URL prefix for any entities returned in a query. @@ -89,8 +89,7 @@ def __init__(self, self.retry_wait = retry_wait def get_last_response(self): - """ - Return last received response. + """Return last received response. :return: Response object from last request or None """ @@ -101,8 +100,7 @@ def select(self, full_data: bool = False, headers: dict[str, str] | None = None ) -> list[dict[str, str]] | None: - """ - Run SPARQL query and return the result. + """Run SPARQL query and return the result. The response is assumed to be in format defined by: https://www.w3.org/TR/2013/REC-sparql11-results-json-20130321/ @@ -142,6 +140,8 @@ def query(self, query: str, headers: dict[str, str] | None = None): .. versionchanged:: 8.5 :exc:`exceptions.NoUsernameError` is raised if the response looks like the user is not logged in. + .. versionchanged:: 9.6 + retry on internal server error (500). :param query: Query text :raises NoUsernameError: User not logged in @@ -156,9 +156,14 @@ def query(self, query: str, headers: dict[str, str] | None = None): while True: try: self.last_response = http.fetch(url, headers=headers) - break except Timeout: - self.wait() + pass + except ServerError as e: + if not e.unicode.startswith('500'): + raise + else: + break + self.wait() try: return self.last_response.json() @@ -184,8 +189,7 @@ def query(self, query: str, headers: dict[str, str] | None = None): def ask(self, query: str, headers: dict[str, str] | None = None) -> bool: - """ - Run SPARQL ASK query and return boolean result. + """Run SPARQL ASK query and return boolean result. :param query: Query text """ @@ -195,8 +199,7 @@ def ask(self, query: str, return data['boolean'] def get_items(self, query, item_name: str = 'item', result_type=set): - """ - Retrieve items which satisfy given query. + """Retrieve items which satisfy given query. Items are returned as Wikibase IDs. @@ -216,6 +219,7 @@ def get_items(self, query, item_name: str = 'item', result_type=set): class SparqlNode: + """Base class for SPARQL nodes.""" def __init__(self, value) -> None: @@ -227,6 +231,7 @@ def __str__(self) -> str: class URI(SparqlNode): + """Representation of URI result type.""" def __init__(self, data: dict, entity_url, **kwargs) -> None: @@ -235,8 +240,7 @@ def __init__(self, data: dict, entity_url, **kwargs) -> None: self.entity_url = entity_url def getID(self): # noqa: N802 - """ - Get ID of Wikibase object identified by the URI. + """Get ID of Wikibase object identified by the URI. :return: ID of Wikibase object, e.g. Q1234 """ @@ -249,6 +253,7 @@ def __repr__(self) -> str: class Literal(SparqlNode): + """Representation of RDF literal result type.""" def __init__(self, data: dict, **kwargs) -> None: @@ -266,6 +271,7 @@ def __repr__(self) -> str: class Bnode(SparqlNode): + """Representation of blank node.""" def __init__(self, data: dict, **kwargs) -> None: diff --git a/pywikibot/data/superset.py b/pywikibot/data/superset.py index 6e223df1b4..7fd0e14e3f 100644 --- a/pywikibot/data/superset.py +++ b/pywikibot/data/superset.py @@ -9,6 +9,7 @@ # from __future__ import annotations +from http import HTTPStatus from textwrap import fill from typing import TYPE_CHECKING, Any @@ -23,6 +24,7 @@ class SupersetQuery(WaitingMixin): + """Superset Query class. This class allows to run SQL queries against wikimedia superset @@ -90,9 +92,9 @@ def login(self) -> bool: self.last_response = http.fetch(url) # Handle error cases - if self.last_response.status_code == 200: + if self.last_response.status_code == HTTPStatus.OK: self.connected = True - elif self.last_response.status_code == 401: + elif self.last_response.status_code == HTTPStatus.UNAUTHORIZED: self.connected = False raise NoUsernameError(fill( 'User not logged in. You need to log in to ' @@ -123,7 +125,7 @@ def get_csrf_token(self) -> str: url = f'{self.superset_url}/api/v1/security/csrf_token/' self.last_response = http.fetch(url) - if self.last_response.status_code == 200: + if self.last_response.status_code == HTTPStatus.OK: return self.last_response.json()['result'] status_code = self.last_response.status_code @@ -146,12 +148,12 @@ def get_database_id_by_schema_name(self, schema_name: str) -> int: url += f'/api/v1/database/{database_id}/schemas/?q=(force:!f)' self.last_response = http.fetch(url) - if self.last_response.status_code == 200: + if self.last_response.status_code == HTTPStatus.OK: schemas = self.last_response.json()['result'] if schema_name in schemas: return database_id - elif self.last_response.status_code == 404: + elif self.last_response.status_code == HTTPStatus.NOT_FOUND: break else: status_code = self.last_response.status_code diff --git a/pywikibot/data/wikistats.py b/pywikibot/data/wikistats.py index 23b2547869..4c6a0c08fa 100644 --- a/pywikibot/data/wikistats.py +++ b/pywikibot/data/wikistats.py @@ -20,8 +20,7 @@ class WikiStats: - """ - Light wrapper around WikiStats data, caching responses and data. + """Light wrapper around WikiStats data, caching responses and data. The methods accept a Pywikibot family name as the WikiStats table name, mapping the names before calling the WikiStats API. @@ -114,8 +113,7 @@ def get_dict(self, table: str) -> dict: def sorted(self, table: str, key: str, reverse: bool | None = None) -> list: - """ - Reverse numerical sort of data. + """Reverse numerical sort of data. :param table: name of table of data :param key: data table key diff --git a/pywikibot/date.py b/pywikibot/date.py index 62f64908ad..b2f40e9e59 100644 --- a/pywikibot/date.py +++ b/pywikibot/date.py @@ -116,8 +116,7 @@ def dh_noConv(value: int, pattern: str, limit: Callable[[int], bool]) -> str: def dh_dayOfMnth(value: int, pattern: str) -> str: - """ - Helper for decoding a single integer value. + """Helper for decoding a single integer value. The single integer should be <=31, no conversion, no rounding (used in days of month). @@ -127,8 +126,7 @@ def dh_dayOfMnth(value: int, pattern: str) -> str: def dh_mnthOfYear(value: int, pattern: str) -> str: - """ - Helper for decoding a single integer value. + """Helper for decoding a single integer value. The value should be >=1000, no conversion, no rounding (used in month of the year) @@ -137,8 +135,7 @@ def dh_mnthOfYear(value: int, pattern: str) -> str: def dh_decAD(value: int, pattern: str) -> str: - """ - Helper for decoding a single integer value. + """Helper for decoding a single integer value. It should be no conversion, round to decimals (used in decades) """ @@ -147,8 +144,7 @@ def dh_decAD(value: int, pattern: str) -> str: def dh_decBC(value: int, pattern: str) -> str: - """ - Helper for decoding a single integer value. + """Helper for decoding a single integer value. It should be no conversion, round to decimals (used in decades) """ @@ -276,8 +272,7 @@ def _(value: str, ind: int, match: str) -> int: def alwaysTrue(x: Any) -> bool: - """ - Return True, always. + """Return True, always. Used for multiple value selection function to accept all other values. @@ -513,8 +508,9 @@ def dh(value: int, pattern: str, encf: encf_type, decf: decf_type, if isinstance(params, (tuple, list)): assert len(params) == len(decoders), ( - 'parameter count ({}) does not match decoder count ({})' - .format(len(params), len(decoders))) + f'parameter count ({len(params)}) does not match decoder count ' + f'({len(decoders)})' + ) # convert integer parameters into their textual representation str_params = tuple(_make_parameter(decoders[i], param) for i, param in enumerate(params)) @@ -779,26 +775,26 @@ def _period_with_pattern(period: str, pattern: str): 'zh': lambda v: dh_number(v, '%d'), }, - 'YearAD': defaultdict(lambda: dh_simpleYearAD, **{ - 'bn': lambda v: dh_yearAD(v, '%B'), - 'fa': lambda v: dh_yearAD(v, '%F (میلادی)'), - 'gan': lambda v: dh_yearAD(v, '%d年'), - 'gu': lambda v: dh_yearAD(v, '%G'), - 'hi': lambda v: dh_yearAD(v, '%H'), - 'hr': lambda v: dh_yearAD(v, '%d.'), - 'ja': lambda v: dh_yearAD(v, '%d年'), - 'jbo': lambda v: dh_yearAD(v, '%dmoi nanca'), - 'kn': lambda v: dh_yearAD(v, '%K'), - 'ko': lambda v: dh_yearAD(v, '%d년'), - 'ksh': lambda v: dh_yearAD(v, 'Joohr %d'), - 'mr': lambda v: dh_yearAD(v, 'ई.स. %H'), - 'nan': lambda v: dh_yearAD(v, '%d nî'), - 'ru': lambda v: dh_yearAD(v, '%d год'), - # 2005 => 'พ.ศ. 2548' - 'th': lambda v: dh_yearAD(v, 'พ.ศ. %T'), - 'ur': lambda v: dh_yearAD(v, '%dء'), - 'zh': lambda v: dh_yearAD(v, '%d年'), - }), + 'YearAD': defaultdict( + lambda: dh_simpleYearAD, + bn=lambda v: dh_yearAD(v, '%B'), + fa=lambda v: dh_yearAD(v, '%F (میلادی)'), + gan=lambda v: dh_yearAD(v, '%d年'), + gu=lambda v: dh_yearAD(v, '%G'), + hi=lambda v: dh_yearAD(v, '%H'), + hr=lambda v: dh_yearAD(v, '%d.'), + ja=lambda v: dh_yearAD(v, '%d年'), + jbo=lambda v: dh_yearAD(v, '%dmoi nanca'), + kn=lambda v: dh_yearAD(v, '%K'), + ko=lambda v: dh_yearAD(v, '%d년'), + ksh=lambda v: dh_yearAD(v, 'Joohr %d'), + mr=lambda v: dh_yearAD(v, 'ई.स. %H'), + nan=lambda v: dh_yearAD(v, '%d nî'), + ru=lambda v: dh_yearAD(v, '%d год'), + th=lambda v: dh_yearAD(v, 'พ.ศ. %T'), + ur=lambda v: dh_yearAD(v, '%dء'), + zh=lambda v: dh_yearAD(v, '%d年'), + ), 'YearBC': { 'af': lambda v: dh_yearBC(v, '%d v.C.'), @@ -1864,9 +1860,10 @@ def makeMonthNamedList(lang: str, pattern: str = '%s', # for all other days formats[dayMnthFmts[i]]['br'] = eval( 'lambda m: multi(m, [' - '(lambda v: dh_dayOfMnth(v, "%dañ {mname}"), lambda p: p == 1), ' - '(lambda v: dh_dayOfMnth(v, "%d {mname}"), alwaysTrue)])' - .format(mname=brMonthNames[i])) + f'(lambda v: dh_dayOfMnth(v, "%dañ {brMonthNames[i]}"),' + ' lambda p: p == 1), ' + f'(lambda v: dh_dayOfMnth(v, "%d {brMonthNames[i]}"), alwaysTrue)])' + ) # # Month of the Year: "en:May 1976" @@ -1963,8 +1960,7 @@ def _format_limit_dom(days: int) -> tuple[Callable[[int], bool], int, int]: def getAutoFormat(lang: str, title: str, ignoreFirstLetterCase: bool = True ) -> tuple[str | None, str | None]: - """ - Return first matching formatted date value. + """Return first matching formatted date value. :param lang: language code :param title: value to format @@ -2022,8 +2018,7 @@ def formatYear(lang: str, year: int) -> str: def apply_month_delta(date: datetime.date, month_delta: int = 1, add_overlap: bool = False) -> datetime.date: - """ - Add or subtract months from the date. + """Add or subtract months from the date. By default if the new month has less days then the day of the date it chooses the last day in the new month. For example a date in the March 31st @@ -2055,8 +2050,7 @@ def apply_month_delta(date: datetime.date, month_delta: int = 1, def get_month_delta(date1: datetime.date, date2: datetime.date) -> int: - """ - Return the difference between two dates in months. + """Return the difference between two dates in months. It does only work on calendars with 12 months per year, and where the months are consecutive and non-negative numbers. diff --git a/pywikibot/diff.py b/pywikibot/diff.py index 49f5f72051..005da215d5 100644 --- a/pywikibot/diff.py +++ b/pywikibot/diff.py @@ -43,8 +43,7 @@ def __init__(self, a: str | Sequence[str], b: str | Sequence[str], grouped_opcode: Sequence[tuple[str, int, int, int, int]] ) -> None: - """ - Initializer. + """Initializer. :param a: sequence of lines :param b: sequence of lines @@ -63,22 +62,16 @@ def __init__(self, a: str | Sequence[str], '+': 'lightgreen', '-': 'lightred', } - self.diff = list(self.create_diff()) self.diff_plain_text = ''.join(self.diff) self.diff_text = ''.join(self.format_diff()) - first, last = self.group[0], self.group[-1] self.a_rng = (first[1], last[2]) self.b_rng = (first[3], last[4]) - self.header = self.get_header() - self.diff_plain_text = '{hunk.header}\n{hunk.diff_plain_text}' \ - .format(hunk=self) + self.diff_plain_text = f'{self.header}\n{self.diff_plain_text}' self.diff_text = str(self.diff_text) - self.reviewed = self.PENDING - self.pre_context = 0 self.post_context = 0 @@ -92,7 +85,7 @@ def get_header_text(a_rng: tuple[int, int], b_rng: tuple[int, int], """Provide header for any ranges.""" a_rng = _format_range_unified(*a_rng) b_rng = _format_range_unified(*b_rng) - return '{0} -{1} +{2} {0}'.format(affix, a_rng, b_rng) + return f'{affix} -{a_rng} +{b_rng} {affix}' def create_diff(self) -> Iterable[str]: """Generator of diff text for this hunk, without formatting. @@ -196,10 +189,9 @@ def color_line(self, line: str, line_ref: str | None = None) -> str: apply_color = 'default;' + self.bg_colors[color] char_tagged = f'<<{apply_color}>>{char}' color_closed = False - else: - if char_ref == ' ': - char_tagged = f'<<default>>{char}' - color_closed = True + elif char_ref == ' ': + char_tagged = f'<<default>>{char}' + color_closed = True colored_line += char_tagged if not color_closed: @@ -383,9 +375,10 @@ def extend_context(start: int, end: int) -> str: context_range = self._get_context_range(hunks) - output = '<<aqua>>{}<<default>>\n{}'.format( - Hunk.get_header_text(*context_range), - extend_context(context_range[0][0], hunks[0].a_rng[0])) + output = ( + f'<<aqua>>{Hunk.get_header_text(*context_range)}<<default>>\n' + f'{extend_context(context_range[0][0], hunks[0].a_rng[0])}' + ) previous_hunk = None for hunk in hunks: if previous_hunk: diff --git a/pywikibot/editor.py b/pywikibot/editor.py index 7b470780c1..ec42bdd0c8 100644 --- a/pywikibot/editor.py +++ b/pywikibot/editor.py @@ -101,8 +101,7 @@ def _concat(command: Sequence[str]) -> str: def edit(self, text: str, jumpIndex: int | None = None, highlight: str | None = None) -> str | None: - """ - Call the editor and thus allows the user to change the text. + """Call the editor and thus allows the user to change the text. Halts the thread's operation until the editor is closed. diff --git a/pywikibot/exceptions.py b/pywikibot/exceptions.py index ec403e20df..de528b4611 100644 --- a/pywikibot/exceptions.py +++ b/pywikibot/exceptions.py @@ -262,8 +262,7 @@ class UploadError(APIError): def __init__(self, code: str, message: str, file_key: str | None = None, offset: int | bool = 0) -> None: - """ - Create a new UploadError instance. + """Create a new UploadError instance. :param file_key: The file_key of the uploaded file to reuse it later. If no key is known or it is an incomplete file it may be None. @@ -282,8 +281,7 @@ def message(self) -> str: class PageRelatedError(Error): - """ - Abstract Exception, used when the exception concerns a particular Page. + """Abstract Exception, used when the exception concerns a particular Page. This class should be used when the Exception concerns a particular Page, and when a generic message can be written once for all. @@ -295,8 +293,7 @@ class PageRelatedError(Error): def __init__(self, page: pywikibot.page.BasePage, message: str | None = None) -> None: - """ - Initializer. + """Initializer. :param page: Page that caused the exception """ @@ -479,8 +476,7 @@ class CircularRedirectError(PageRelatedError): class InterwikiRedirectPageError(PageRelatedError): - """ - Page is a redirect to another site. + """Page is a redirect to another site. This is considered invalid in Pywikibot. See bug :phab:`T75184`. @@ -699,8 +695,7 @@ class NoWikibaseEntityError(WikiBaseError): """This entity doesn't exist.""" def __init__(self, entity: pywikibot.page.WikibaseEntity) -> None: - """ - Initializer. + """Initializer. :param entity: Wikibase entity """ diff --git a/pywikibot/families/wikiquote_family.py b/pywikibot/families/wikiquote_family.py index a1e2e2f9ef..1a032a039b 100644 --- a/pywikibot/families/wikiquote_family.py +++ b/pywikibot/families/wikiquote_family.py @@ -70,8 +70,7 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): } def encodings(self, code): - """ - Return a list of historical encodings for a specific language. + """Return a list of historical encodings for a specific language. :param code: site code """ diff --git a/pywikibot/families/wikivoyage_family.py b/pywikibot/families/wikivoyage_family.py index bc541fc2b2..9f0b9424ac 100644 --- a/pywikibot/families/wikivoyage_family.py +++ b/pywikibot/families/wikivoyage_family.py @@ -18,8 +18,8 @@ class Family(family.SubdomainFamily, family.WikimediaFamily): codes = { 'bn', 'cs', 'de', 'el', 'en', 'eo', 'es', 'fa', 'fi', 'fr', 'he', 'hi', - 'it', 'ja', 'nl', 'pl', 'ps', 'pt', 'ro', 'ru', 'shn', 'sv', 'tr', - 'uk', 'vi', 'zh', + 'id', 'it', 'ja', 'nl', 'pl', 'ps', 'pt', 'ro', 'ru', 'shn', 'sv', + 'tr', 'uk', 'vi', 'zh', } category_redirect_templates = { diff --git a/pywikibot/family.py b/pywikibot/family.py index 6cb4ece163..b96bf1402c 100644 --- a/pywikibot/family.py +++ b/pywikibot/family.py @@ -377,17 +377,16 @@ def load(fam: str | None = None): # codes can accept also underscore/dash. if not all(x in NAME_CHARACTERS for x in cls.name): warnings.warn( - 'Name of family {} must be ASCII letters and digits ' - '[a-zA-Z0-9]'.format(cls.name), + f'Name of family {cls.name} must be ASCII letters and digits' + ' [a-zA-Z0-9]', FamilyMaintenanceWarning, stacklevel=2, ) for code in cls.langs: if not all(x in CODE_CHARACTERS for x in code): warnings.warn( - 'Family {} code {} must be ASCII lowercase letters and ' - 'digits [a-z0-9] or underscore/dash [_-]' - .format(cls.name, code), + f'Family {cls.name} code {code} must be ASCII lowercase' + ' letters and digits [a-z0-9] or underscore/dash [_-]', FamilyMaintenanceWarning, stacklevel=2, ) @@ -482,8 +481,7 @@ def protocol(self, code: str) -> str: return 'https' def verify_SSL_certificate(self, code: str) -> bool: - """ - Return whether a HTTPS certificate should be verified. + """Return whether a HTTPS certificate should be verified. .. versionadded:: 5.3 renamed from ignore_certificate_error @@ -535,8 +533,7 @@ def _hostname(self, code, protocol=None): return protocol, host def base_url(self, code: str, uri: str, protocol=None) -> str: - """ - Prefix uri with port and hostname. + """Prefix uri with port and hostname. :param code: The site code :param uri: The absolute path after the hostname @@ -712,8 +709,7 @@ def isPublic(self, code) -> bool: return True def post_get_convert(self, site, getText): - """ - Do a conversion on the retrieved text from the Wiki. + """Do a conversion on the retrieved text from the Wiki. For example a :wiki:`X-conversion in Esperanto <Esperanto_orthography#X-system>`. @@ -721,8 +717,7 @@ def post_get_convert(self, site, getText): return getText def pre_put_convert(self, site, putText): - """ - Do a conversion on the text to insert on the Wiki. + """Do a conversion on the text to insert on the Wiki. For example a :wiki:`X-conversion in Esperanto <Esperanto_orthography#X-system>`. @@ -731,8 +726,7 @@ def pre_put_convert(self, site, putText): @property def obsolete(self) -> types.MappingProxyType[str, str | None]: - """ - Old codes that are not part of the family. + """Old codes that are not part of the family. Interwiki replacements override removals for the same code. @@ -744,8 +738,7 @@ def obsolete(self) -> types.MappingProxyType[str, str | None]: @classproperty def domains(cls) -> set[str]: - """ - Get list of unique domain names included in this family. + """Get list of unique domain names included in this family. These domains may also exist in another family. """ @@ -1165,8 +1158,7 @@ def globes(self, code): def AutoFamily(name: str, url: str) -> SingleSiteFamily: - """ - Family that automatically loads the site configuration. + """Family that automatically loads the site configuration. :param name: Name for the family :param url: API endpoint URL of the wiki diff --git a/pywikibot/flow.py b/pywikibot/flow.py index 1e1db84a58..e5b136563b 100644 --- a/pywikibot/flow.py +++ b/pywikibot/flow.py @@ -109,7 +109,8 @@ def get(self, force: bool = False, get_redirect: bool = False if get_redirect or force: raise NotImplementedError( "Neither 'force' nor 'get_redirect' parameter is implemented " - 'in {}.get()'.format(self.__class__.__name__)) + f'in {self.__class__.__name__}.get()' + ) # TODO: Return more useful data return getattr(self, '_data', {}) @@ -423,8 +424,7 @@ def __init__(self, page: Topic, uuid: str) -> None: @classmethod def fromJSON(cls, page: Topic, post_uuid: str, # noqa: N802 data: dict[str, Any]) -> Post: - """ - Create a Post object using the data returned from the API call. + """Create a Post object using the data returned from the API call. :param page: A Flow topic :param post_uuid: The UUID of the post diff --git a/pywikibot/i18n.py b/pywikibot/i18n.py index f3ac0fb866..0c683af039 100644 --- a/pywikibot/i18n.py +++ b/pywikibot/i18n.py @@ -368,8 +368,7 @@ def set_messages_package(package_name: str) -> None: def messages_available() -> bool: - """ - Return False if there are no i18n messages available. + """Return False if there are no i18n messages available. To determine if messages are available, it looks for the package name set using :py:obj:`set_messages_package` for a message bundle called @@ -438,8 +437,7 @@ def _get_bundle(lang: str, dirname: str) -> dict[str, str]: def _get_translation(lang: str, twtitle: str) -> str | None: - """ - Return message of certain twtitle if exists. + """Return message of certain twtitle if exists. For internal use, don't use it directly. """ @@ -466,8 +464,8 @@ def replace_plural(match: Match[str]) -> str: variants = match[2] num = parameters[selector] if not isinstance(num, int): - raise ValueError("'{}' must be a number, not a {} ({})" - .format(selector, num, type(num).__name__)) + raise ValueError(f"'{selector}' must be a number, not a {num} " + f'({type(num).__name__})') plural_entries = [] specific_entries = {} @@ -515,8 +513,7 @@ def replace_plural(match: Match[str]) -> str: class _PluralMappingAlias(abc.Mapping): - """ - Aliasing class to allow non mappings in _extract_plural. + """Aliasing class to allow non mappings in _extract_plural. That function only uses __getitem__ so this is only implemented here. """ @@ -638,8 +635,9 @@ def translate(code: str | pywikibot.site.BaseSite, return trans if not isinstance(parameters, Mapping): - raise ValueError('parameters should be a mapping, not {}' - .format(type(parameters).__name__)) + raise ValueError( + f'parameters should be a mapping, not {type(parameters).__name__}' + ) # else we check for PLURAL variants trans = _extract_plural(code, trans, parameters) @@ -699,8 +697,7 @@ def twtranslate( only_plural: bool = False, bot_prefix: bool = False ) -> str | None: - r""" - Translate a message using JSON files in messages_package_name. + r"""Translate a message using JSON files in messages_package_name. fallback parameter must be True for i18n and False for L10N or testing purposes. @@ -780,10 +777,10 @@ def twtranslate( return fallback_prompt raise pywikibot.exceptions.TranslationError( - 'Unable to load messages package {} for bundle {}' - '\nIt can happen due to lack of i18n submodule or files. ' - 'See {}/i18n' - .format(_messages_package_name, twtitle, __url__)) + 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' + ) # if source is a site then use its lang attribute, otherwise it's a str lang = getattr(source, 'lang', source) @@ -814,8 +811,9 @@ def twtranslate( trans = _extract_plural(alt, trans, parameters) if parameters is not None and not isinstance(parameters, Mapping): - raise ValueError('parameters should be a mapping, not {}' - .format(type(parameters).__name__)) + raise ValueError( + f'parameters should be a mapping, not {type(parameters).__name__}' + ) if not only_plural and parameters: trans = trans % parameters @@ -823,8 +821,7 @@ def twtranslate( def twhas_key(source: str | pywikibot.site.BaseSite, twtitle: str) -> bool: - """ - Check if a message has a translation in the specified language code. + """Check if a message has a translation in the specified language code. The translations are retrieved from i18n.<package>, based on the callers import table. @@ -842,8 +839,7 @@ def twhas_key(source: str | pywikibot.site.BaseSite, twtitle: str) -> bool: def twget_keys(twtitle: str) -> list[str]: - """ - Return all language codes for a special message. + """Return all language codes for a special message. :param twtitle: The TranslateWiki string title, in <package>-<key> format @@ -938,8 +934,7 @@ def input(twtitle: str, parameters: Mapping[str, int] | None = None, password: bool = False, fallback_prompt: str | None = None) -> str: - """ - Ask the user a question, return the user's answer. + """Ask the user a question, return the user's answer. The prompt message is retrieved via :py:obj:`twtranslate` and uses the config variable 'userinterface_lang'. @@ -956,8 +951,9 @@ def input(twtitle: str, prompt = fallback_prompt else: raise pywikibot.exceptions.TranslationError( - 'Unable to load messages package {} for bundle {}' - .format(_messages_package_name, twtitle)) + f'Unable to load messages package {_messages_package_name} for ' + f'bundle {twtitle}' + ) return pywikibot.input(prompt, password) diff --git a/pywikibot/interwiki_graph.py b/pywikibot/interwiki_graph.py index 3efacb522a..7925679679 100644 --- a/pywikibot/interwiki_graph.py +++ b/pywikibot/interwiki_graph.py @@ -23,8 +23,7 @@ class GraphSavingThread(threading.Thread): - """ - Threaded graph renderer. + """Threaded graph renderer. Rendering a graph can take extremely long. We use multithreading because of that. @@ -111,9 +110,8 @@ def addNode(self, page: pywikibot.page.Page) -> None: """Add a node for page.""" assert self.graph is not None node = pydot.Node(self.getLabel(page), shape='rectangle') - node.set_URL('"http://{}{}"' - .format(page.site.hostname(), - page.site.get_address(page.title(as_url=True)))) + node.set_URL(f'"http://{page.site.hostname()}' + f'{page.site.get_address(page.title(as_url=True))}"') node.set_style('filled') node.set_fillcolor('white') node.set_fontsize('11') @@ -176,8 +174,7 @@ def saveGraphFile(self) -> None: thread.start() def createGraph(self) -> None: - """ - Create graph of the interwiki links. + """Create graph of the interwiki links. For more info see https://meta.wikimedia.org/wiki/Interwiki_graphs """ @@ -202,8 +199,7 @@ def createGraph(self) -> None: def getFilename(page: pywikibot.page.Page, extension: str | None = None) -> str: - """ - Create a filename that is unique for the page. + """Create a filename that is unique for the page. :param page: page used to create the new filename :param extension: file extension diff --git a/pywikibot/logentries.py b/pywikibot/logentries.py index 8442df3594..9b25276368 100644 --- a/pywikibot/logentries.py +++ b/pywikibot/logentries.py @@ -39,8 +39,8 @@ def __init__(self, apidata: dict[str, Any], self.site = site expected_type = self._expected_type if expected_type is not None and expected_type != self.type(): - raise Error('Wrong log type! Expecting {}, received {} instead.' - .format(expected_type, self.type())) + raise Error(f'Wrong log type! Expecting {expected_type}, received ' + f'{self.type()} instead.') def __missing__(self, key: str) -> None: """Debug when the key is missing. @@ -79,8 +79,8 @@ def __hash__(self) -> int: def __eq__(self, other: Any) -> bool: """Compare if self is equal to other.""" if not isinstance(other, LogEntry): - pywikibot.debug("'{}' cannot be compared with '{}'" - .format(type(self).__name__, type(other).__name__)) + pywikibot.debug(f"'{type(self).__name__}' cannot be compared with " + f"'{type(other).__name__}'") return False return self.logid() == other.logid() and self.site == other.site @@ -104,8 +104,7 @@ def params(self) -> dict[str, Any]: @cached def page(self) -> int | pywikibot.page.Page: - """ - Page on which action was performed. + """Page on which action was performed. :return: page on action was performed """ @@ -140,8 +139,7 @@ def page(self) -> pywikibot.page.User: class BlockEntry(LogEntry): - """ - Block or unblock log entry. + """Block or unblock log entry. It might contain a block or unblock depending on the action. The duration, expiry and flags are not available on unblock log entries. @@ -161,8 +159,7 @@ def __init__(self, apidata: dict[str, Any], self._blockid = int(self['title'][pos + 1:]) def page(self) -> int | pywikibot.page.Page: - """ - Return the blocked account or IP. + """Return the blocked account or IP. :return: the Page object of username or IP if this block action targets a username or IP, or the blockid if this log reflects @@ -176,8 +173,7 @@ def page(self) -> int | pywikibot.page.Page: @cached def flags(self) -> list[str]: - """ - Return a list of (str) flags associated with the block entry. + """Return a list of (str) flags associated with the block entry. It raises an Error if the entry is an unblocking log entry. @@ -190,8 +186,7 @@ def flags(self) -> list[str]: @cached def duration(self) -> datetime.timedelta | None: - """ - Return a datetime.timedelta representing the block duration. + """Return a datetime.timedelta representing the block duration. :return: datetime.timedelta, or None if block is indefinite. """ @@ -299,8 +294,7 @@ def auto(self) -> bool: class LogEntryFactory: - """ - LogEntry Factory. + """LogEntry Factory. Only available method is create() """ @@ -315,8 +309,7 @@ class LogEntryFactory: def __init__(self, site: pywikibot.site.BaseSite, logtype: str | None = None) -> None: - """ - Initializer. + """Initializer. :param site: The site on which the log entries are created. :param logtype: The log type of the log entries, if known in advance. @@ -333,8 +326,7 @@ def __init__(self, site: pywikibot.site.BaseSite, self._creator = lambda data: logclass(data, self._site) def create(self, logdata: dict[str, Any]) -> LogEntry: - """ - Instantiate the LogEntry object representing logdata. + """Instantiate the LogEntry object representing logdata. :param logdata: <item> returned by the api @@ -343,8 +335,7 @@ def create(self, logdata: dict[str, Any]) -> LogEntry: return self._creator(logdata) def get_valid_entry_class(self, logtype: str) -> LogEntry: - """ - Return the class corresponding to the @logtype string parameter. + """Return the class corresponding to the @logtype string parameter. :return: specified subclass of LogEntry :raise KeyError: logtype is not valid @@ -356,8 +347,7 @@ def get_valid_entry_class(self, logtype: str) -> LogEntry: @classmethod def get_entry_class(cls, logtype: str) -> LogEntry: - """ - Return the class corresponding to the @logtype string parameter. + """Return the class corresponding to the @logtype string parameter. :return: specified subclass of LogEntry @@ -384,8 +374,7 @@ def get_entry_class(cls, logtype: str) -> LogEntry: return cls._logtypes[logtype] def _create_from_data(self, logdata: dict[str, Any]) -> LogEntry: - """ - Check for logtype from data, and creates the correct LogEntry. + """Check for logtype from data, and creates the correct LogEntry. :param logdata: log entry data """ diff --git a/pywikibot/logging.py b/pywikibot/logging.py index 02bbce90a0..ef028cea8a 100644 --- a/pywikibot/logging.py +++ b/pywikibot/logging.py @@ -138,8 +138,8 @@ def logoutput(msg: Any, f'keyword argument "{key}={arg}"', since='7.2.0') if key in kwargs: - warning('{!r} is given as keyword argument {!r} already; ignoring ' - '{!r}'.format(key, arg, kwargs[key])) + warning(f'{key!r} is given as keyword argument {arg!r} already; ' + f'ignoring {kwargs[key]!r}') else: kwargs[key] = arg diff --git a/pywikibot/login.py b/pywikibot/login.py index c109a21171..1a714e16c7 100644 --- a/pywikibot/login.py +++ b/pywikibot/login.py @@ -46,8 +46,7 @@ class _PasswordFileWarning(UserWarning): class LoginStatus(IntEnum): - """ - Enum for Login statuses. + """Enum for Login statuses. >>> LoginStatus.NOT_ATTEMPTED LoginStatus(-3) @@ -80,8 +79,7 @@ class LoginManager: def __init__(self, password: str | None = None, site: pywikibot.site.BaseSite | None = None, user: str | None = None) -> None: - """ - Initializer. + """Initializer. All parameters default to defaults in user-config. @@ -102,20 +100,20 @@ def __init__(self, password: str | None = None, user = code_to_usr.get(site.code) or code_to_usr['*'] except KeyError: raise NoUsernameError( - 'ERROR: ' - 'username for {site.family.name}:{site.code} is undefined.' - '\nIf you have a username for that site, please add a ' - 'line to user config file (user-config.py) as follows:\n' - "usernames['{site.family.name}']['{site.code}'] = " - "'myUsername'".format(site=site)) + f'ERROR: username for {site.family.name}:{site.code} is' + ' undefined.\nIf you have a username for that site,' + ' please add a line to user config file (user-config.py)' + ' as follows:\n' + f"usernames['{site.family.name}']['{site.code}'] =" + " 'myUsername'" + ) self.password = password self.login_name = self.username = user if getattr(config, 'password_file', ''): self.readPassword() def check_user_exists(self) -> None: - """ - Check that the username exists on the site. + """Check that the username exists on the site. .. seealso:: :api:`Users` @@ -125,11 +123,10 @@ def check_user_exists(self) -> None: # convert any Special:BotPassword usernames to main account equivalent main_username = self.username if '@' in self.username: - warn( - 'When using BotPasswords it is recommended that you store ' - 'your login credentials in a password_file instead. See ' - '{}/BotPasswords for instructions and more information.' - .format(__url__)) + warn('When using BotPasswords it is recommended that you store' + ' your login credentials in a password_file instead. See ' + f'{__url__}/BotPasswords for instructions and more' + ' information.') main_username = self.username.partition('@')[0] try: @@ -137,19 +134,19 @@ def check_user_exists(self) -> None: user = next(data, {'name': None}) except APIError as e: if e.code == 'readapidenied': - pywikibot.warning("Could not check user '{}' exists on {}" - .format(main_username, self.site)) + pywikibot.warning(f"Could not check user '{main_username}' " + f'exists on {self.site}') return raise if user['name'] != main_username: # Report the same error as server error code NotExists - raise NoUsernameError("Username '{}' does not exist on {}" - .format(main_username, self.site)) + raise NoUsernameError( + f"Username '{main_username}' does not exist on {self.site}" + ) def botAllowed(self) -> bool: - """ - Check whether the bot is listed on a specific page. + """Check whether the bot is listed on a specific page. This allows bots to comply with the policy on the respective wiki. """ @@ -264,8 +261,7 @@ def readPassword(self) -> None: } def login(self, retry: bool = False, autocreate: bool = False) -> bool: - """ - Attempt to log into the server. + """Attempt to log into the server. .. seealso:: :api:`Login` @@ -285,9 +281,10 @@ def login(self, retry: bool = False, autocreate: bool = False) -> bool: # As we don't want the password to appear on the screen, we set # password = True self.password = pywikibot.input( - 'Password for user {name} on {site} (no characters will be ' - 'shown):'.format(name=self.login_name, site=self.site), - password=True) + f'Password for user {self.login_name} on {self.site}' + ' (no characters will be shown):', + password=True + ) else: pywikibot.info(f'Logging in to {self.site} as {self.login_name}') @@ -298,8 +295,8 @@ def login(self, retry: bool = False, autocreate: bool = False) -> bool: # TODO: investigate other unhandled API codes if error_code in self._api_error: - error_msg = 'Username {!r} {} on {}'.format( - self.login_name, self._api_error[error_code], self.site) + error_msg = (f'Username {self.login_name!r} ' + f'{self._api_error[error_code]} on {self.site}') if error_code in ('Failed', 'FAIL'): error_msg += f'.\n{e.info}' raise NoUsernameError(error_msg) @@ -481,8 +478,7 @@ class BotPassword: """BotPassword object for storage in password file.""" def __init__(self, suffix: str, password: str) -> None: - """ - Initializer. + """Initializer. BotPassword function by using a separate password paired with a suffixed username of the form <username>@<suffix>. @@ -499,10 +495,9 @@ def __init__(self, suffix: str, password: str) -> None: self.password = password def login_name(self, username: str) -> str: - """ - Construct the login name from the username and suffix. + """Construct the login name from the username and suffix. - :param user: username (without suffix) + :param username: username (without suffix) """ return f'{username}@{self.suffix}' @@ -517,8 +512,7 @@ class OauthLoginManager(LoginManager): def __init__(self, password: str | None = None, site: pywikibot.site.BaseSite | None = None, user: str | None = None) -> None: - """ - Initializer. + """Initializer. All parameters default to defaults in user-config. @@ -535,16 +529,16 @@ def __init__(self, password: str | None = None, assert password is not None and user is not None super().__init__(password=None, site=site, user=None) if self.password: - pywikibot.warn('Password exists in password file for {login.site}:' - '{login.username}. Password is unnecessary and ' - 'should be removed if OAuth enabled.' - .format(login=self)) + pywikibot.warn( + f'Password exists in password file for {self.site}: ' + f'{self.username}. Password is unnecessary and should be' + ' removed if OAuth enabled.' + ) self._consumer_token = (user, password) self._access_token: tuple[str, str] | None = None def login(self, retry: bool = False, force: bool = False) -> bool: - """ - Attempt to log into the server. + """Attempt to log into the server. .. seealso:: :api:`Login` @@ -553,9 +547,8 @@ def login(self, retry: bool = False, force: bool = False) -> bool: :param force: force to re-authenticate """ if self.access_token is None or force: - pywikibot.info( - 'Logging in to {site} via OAuth consumer {key}' - .format(key=self.consumer_token[0], site=self.site)) + pywikibot.info(f'Logging in to {self.site} via OAuth consumer ' + f'{self.consumer_token[0]}') consumer_token = mwoauth.ConsumerToken(*self.consumer_token) handshaker = mwoauth.Handshaker( self.site.base_url(self.site.path()), consumer_token) @@ -563,9 +556,10 @@ def login(self, retry: bool = False, force: bool = False) -> bool: redirect, request_token = handshaker.initiate() pywikibot.stdout('Authenticate via web browser..') webbrowser.open(redirect) - pywikibot.stdout('If your web browser does not open ' - 'automatically, please point it to: {}' - .format(redirect)) + pywikibot.stdout( + 'If your web browser does not open automatically, please ' + f'point it to: {redirect}' + ) request_qs = pywikibot.input('Response query string: ') access_token = handshaker.complete(request_token, request_qs) self._access_token = (access_token.key, access_token.secret) @@ -576,14 +570,13 @@ def login(self, retry: bool = False, force: bool = False) -> bool: return self.login(retry=True, force=force) return False else: - pywikibot.info('Logged in to {site} via consumer {key}' - .format(key=self.consumer_token[0], site=self.site)) + pywikibot.info(f'Logged in to {self.site} via consumer ' + f'{self.consumer_token[0]}') return True @property def consumer_token(self) -> tuple[str, str]: - """ - Return OAuth consumer key token and secret token. + """Return OAuth consumer key token and secret token. .. seealso:: :api:`Tokens` """ @@ -591,8 +584,7 @@ def consumer_token(self) -> tuple[str, str]: @property def access_token(self) -> tuple[str, str] | None: - """ - Return OAuth access key token and secret token. + """Return OAuth access key token and secret token. .. seealso:: :api:`Tokens` """ @@ -600,7 +592,12 @@ def access_token(self) -> tuple[str, str] | None: @property def identity(self) -> dict[str, Any] | None: - """Get identifying information about a user via an authorized token.""" + """Get identifying information about a user via an authorized token. + + .. versionchanged:: 9.6 + *leeway* parameter for ``mwoauth.identify`` function was + increased to 30.0 seconds. + """ if self.access_token is None: pywikibot.error('Access token not set') return None @@ -609,8 +606,12 @@ def identity(self) -> dict[str, Any] | None: access_token = mwoauth.AccessToken(*self.access_token) try: identity = mwoauth.identify(self.site.base_url(self.site.path()), - consumer_token, access_token) - return identity + consumer_token, + access_token, + leeway=30.0) except Exception as e: pywikibot.error(e) - return None + else: + return identity + + return None diff --git a/pywikibot/page/_basepage.py b/pywikibot/page/_basepage.py index 1d675fe091..28ad6db0f7 100644 --- a/pywikibot/page/_basepage.py +++ b/pywikibot/page/_basepage.py @@ -60,8 +60,7 @@ class BasePage(ComparableMixin): - """ - BasePage: Base object for a MediaWiki page. + """BasePage: Base object for a MediaWiki page. This object only implements internally methods that do not require reading from or writing to the wiki. All other methods are delegated @@ -79,8 +78,7 @@ class BasePage(ComparableMixin): ) def __init__(self, source, title: str = '', ns=0) -> None: - """ - Instantiate a Page object. + """Instantiate a Page object. Three calling formats are supported: @@ -131,9 +129,8 @@ def __init__(self, source, title: str = '', ns=0) -> None: self._link = source self._revisions = {} else: - raise Error( - "Invalid argument type '{}' in Page initializer: {}" - .format(type(source), source)) + raise Error(f"Invalid argument type '{type(source)}' in Page " + f'initializer: {source}') @property def site(self): @@ -144,8 +141,7 @@ def site(self): return self._link.site def version(self): - """ - Return MediaWiki version number of the page site. + """Return MediaWiki version number of the page site. This is needed to use @need_version() decorator for methods of Page objects. @@ -163,8 +159,7 @@ def data_repository(self): return self.site.data_repository() def namespace(self) -> Namespace: - """ - Return the namespace of the page. + """Return the namespace of the page. :return: namespace of the page """ @@ -172,8 +167,7 @@ def namespace(self) -> Namespace: @property def content_model(self): - """ - Return the content model for this page. + """Return the content model for this page. If it cannot be reliably determined via the API, None is returned. @@ -194,8 +188,7 @@ def depth(self) -> int: @property def pageid(self) -> int: - """ - Return pageid of the page. + """Return pageid of the page. :return: pageid or 0 if page does not exist """ @@ -218,8 +211,7 @@ def title( insite=None, without_brackets: bool = False ) -> str: - """ - Return the title of this Page, as a string. + """Return the title of this Page, as a string. :param underscore: (not used with as_link) if true, replace all ' ' characters with '_' @@ -264,8 +256,7 @@ def title( or self.site.code != target_code)): if self.site.family.name not in ( target_family, self.site.code): - title = '{site.family.name}:{site.code}:{title}'.format( - site=self.site, title=title) + title = f'{self.site.family.name}:{self.site.code}:{title}' else: # use this form for sites like commons, where the # code is the same as the family name @@ -314,8 +305,7 @@ def __repr__(self) -> str: return f'{self.__class__.__name__}({self.title()!r})' def _cmpkey(self): - """ - Key for comparison of Page objects. + """Key for comparison of Page objects. Page objects are "equal" if and only if they are on the same site and have the same normalized title, including section if any. @@ -325,8 +315,7 @@ def _cmpkey(self): return (self.site, self.namespace(), self.title()) def __hash__(self): - """ - A stable identifier to be used as a key in hash-tables. + """A stable identifier to be used as a key in hash-tables. This relies on the fact that the string representation of an instance cannot change after the construction. @@ -340,8 +329,7 @@ def full_url(self): @cached def autoFormat(self): - """ - Return :py:obj:`date.getAutoFormat` dictName and value, if any. + """Return :py:obj:`date.getAutoFormat` dictName and value, if any. Value can be a year, date, etc., and dictName is 'YearBC', 'Year_December', or another dictionary name. Please note that two @@ -411,8 +399,7 @@ def get(self, force: bool = False, get_redirect: bool = False) -> str: return text def has_content(self) -> bool: - """ - Page has been loaded. + """Page has been loaded. Not existing pages are considered loaded. @@ -428,8 +415,7 @@ def _latest_cached_revision(self): return None def _getInternals(self): - """ - Helper function for get(). + """Helper function for get(). Stores latest revision in self if it doesn't contain it, doesn't think. * Raises exceptions from previous runs. @@ -452,16 +438,33 @@ def _getInternals(self): self._getexception = IsRedirectPageError(self) raise self._getexception + def get_revision( + self, oldid: int, *, force: bool = False, content: bool = False + ) -> pywikibot.page.Revision: + """Return an old revision of this page. + + .. versionadded:: 9.6 + .. seealso:: :meth:`getOldVersion` + + + :param oldid: The revid of the revision desired. + :param content: if True, retrieve the content of the revision + (default False) + """ + if force or oldid not in self._revisions \ + or (content and self._revisions[oldid].text is None): + self.site.loadrevisions(self, content=content, revids=oldid) + return self._revisions[oldid] + @remove_last_args(['get_redirect']) def getOldVersion(self, oldid, force: bool = False) -> str: """Return text of an old revision of this page. + .. seealso:: :meth:`get_revision` + :param oldid: The revid of the revision desired. """ - if force or oldid not in self._revisions \ - or self._revisions[oldid].text is None: - self.site.loadrevisions(self, content=True, revids=oldid) - return self._revisions[oldid].text + return self.get_revision(oldid, content=True, force=force).text def permalink(self, oldid=None, percent_encoded: bool = True, with_protocol: bool = False) -> str: @@ -493,8 +496,7 @@ def latest_revision_id(self): @latest_revision_id.deleter def latest_revision_id(self) -> None: - """ - Remove the latest revision id set for this Page. + """Remove the latest revision id set for this Page. All internal cached values specifically for the latest revision of this page are cleared. @@ -608,8 +610,7 @@ def text(self) -> None: del self._raw_extracted_templates def preloadText(self) -> str: - """ - The text returned by EditFormPreloadText. + """The text returned by EditFormPreloadText. See API module "info". @@ -686,9 +687,8 @@ def extract(self, variant: str = 'plain', *, extract = shorten(extract, chars, break_long_words=False, placeholder='…') else: - raise ValueError( - 'variant parameter must be "plain", "html" or "wiki", not "{}"' - .format(variant)) + raise ValueError('variant parameter must be "plain", "html" or ' + f'"wiki", not "{variant}"') if not lines: return extract @@ -702,8 +702,7 @@ def extract(self, variant: str = 'plain', *, return '\n'.join(text_lines[:min(lines, len(text_lines))]) def properties(self, force: bool = False) -> dict: - """ - Return the properties of the page. + """Return the properties of the page. :param force: force updating from the live site """ @@ -713,8 +712,7 @@ def properties(self, force: bool = False) -> dict: return self._pageprops def defaultsort(self, force: bool = False) -> str | None: - """ - Extract value of the {{DEFAULTSORT:}} magic word from the page. + """Extract value of the {{DEFAULTSORT:}} magic word from the page. :param force: force updating from the live site """ @@ -765,8 +763,7 @@ def isIpEdit(self) -> bool: @cached def lastNonBotUser(self) -> str | None: - """ - Return name or IP address of last human/non-bot user to edit page. + """Return name or IP address of last human/non-bot user to edit page. Determine the most recent human editor out of the last revisions. If it was not able to retrieve a human user, returns None. @@ -864,9 +861,9 @@ def isCategoryRedirect(self) -> bool: self._catredirect = p.title() else: pywikibot.warning( - 'Category redirect target {} on {} is not a ' - 'category'.format(p.title(as_link=True), - self.title(as_link=True))) + f'Category redirect target {p.title(as_link=True)}' + f' on {self.title(as_link=True)} is not a category' + ) else: pywikibot.warning( 'No target found for category redirect on ' @@ -887,8 +884,7 @@ def isTalkPage(self): return ns >= 0 and ns % 2 == 1 def toggleTalkPage(self) -> pywikibot.Page | None: - """ - Return other member of the article-talk page pair for this Page. + """Return other member of the article-talk page pair for this Page. If self is a talk page, returns the associated content page; otherwise, returns the associated talk page. The returned page need @@ -986,8 +982,7 @@ def getReferences(self, namespaces=None, total: int | None = None, content: bool = False) -> Iterable[pywikibot.Page]: - """ - Return an iterator all pages that refer to or embed the page. + """Return an iterator all pages that refer to or embed the page. If you need a full list of referring pages, use ``pages = list(s.getReferences())`` @@ -1026,8 +1021,7 @@ def backlinks(self, namespaces=None, total: int | None = None, content: bool = False) -> Iterable[pywikibot.Page]: - """ - Return an iterator for pages that link to this page. + """Return an iterator for pages that link to this page. :param follow_redirects: if True, also iterate pages that link to a redirect pointing to the page. @@ -1052,8 +1046,7 @@ def embeddedin(self, namespaces=None, total: int | None = None, content: bool = False) -> Iterable[pywikibot.Page]: - """ - Return an iterator for pages that embed this page as a template. + """Return an iterator for pages that embed this page as a template. :param filter_redirects: if True, only iterate redirects; if False, omit redirects; if None, do not filter @@ -1078,8 +1071,7 @@ def redirects( total: int | None = None, content: bool = False ) -> Iterable[pywikibot.Page]: - """ - Return an iterable of redirects to this page. + """Return an iterable of redirects to this page. :param filter_fragments: If True, only return redirects with fragments. If False, only return redirects without fragments. If None, return @@ -1161,8 +1153,7 @@ def has_permission(self, action: str = 'edit') -> bool: return self.site.page_can_be_edited(self, action) def botMayEdit(self) -> bool: - """ - Determine whether the active bot is allowed to edit the page. + """Determine whether the active bot is allowed to edit the page. This will be True if the page doesn't contain {{bots}} or {{nobots}} or any other template from edit_restricted_templates list @@ -1458,8 +1449,7 @@ def put(self, newtext: str, **kwargs) def watch(self, unwatch: bool = False) -> bool: - """ - Add or remove this page to/from bot account's watchlist. + """Add or remove this page to/from bot account's watchlist. :param unwatch: True to unwatch, False (default) to watch. :return: True if successful, False otherwise. @@ -1474,8 +1464,7 @@ def clear_cache(self) -> None: delattr(self, attr) def purge(self, **kwargs) -> bool: - """ - Purge the server's cache for this page. + """Purge the server's cache for this page. :keyword redirects: Automatically resolve redirects. :type redirects: bool @@ -1558,9 +1547,8 @@ def linkedPages( f'keyword argument "{key}={arg}"', since='7.0.0') if key in kwargs: - pywikibot.warning('{!r} is given as keyword argument {!r} ' - 'already; ignoring {!r}' - .format(key, arg, kwargs[key])) + pywikibot.warning(f'{key!r} is given as keyword argument ' + f'{arg!r} already; ignoring {kwargs[key]!r}') else: kwargs[key] = arg @@ -1570,8 +1558,7 @@ def interwiki( self, expand: bool = True, ) -> Generator[pywikibot.page.Link, None, None]: - """ - Yield interwiki links in the page text, excluding language links. + """Yield interwiki links in the page text, excluding language links. :param expand: if True (default), include interwiki links found in templates transcluded onto this page; if False, only iterate @@ -1602,8 +1589,7 @@ def langlinks( self, include_obsolete: bool = False, ) -> list[pywikibot.Link]: - """ - Return a list of all inter-language Links on this page. + """Return a list of all inter-language Links on this page. :param include_obsolete: if true, return even Link objects whose site is obsolete @@ -1733,8 +1719,7 @@ def imagelinks( total: int | None = None, content: bool = False, ) -> Iterable[pywikibot.FilePage]: - """ - Iterate FilePage objects for images displayed on this Page. + """Iterate FilePage objects for images displayed on this Page. :param total: iterate no more than this number of pages in total :param content: if True, retrieve the content of the current version @@ -1749,20 +1734,25 @@ def categories( total: int | None = None, content: bool = False, ) -> Iterable[pywikibot.Page]: - """ - Iterate categories that the article is in. + """Iterate categories that the article is in. - :param with_sort_key: if True, include the sort key in each Category. + .. versionchanged:: 2.0 + *with_sort_key* parameter is not supported and a + NotImplementedError is raised if set. + .. versionchanged:: 9.6 + *with_sort_key* parameter is supported. + .. seealso:: :meth:`Site.pagecategories() + <pywikibot.site._generators.GeneratorsMixin.pagecategories>` + .. note:: This method also yields categories which are + transcluded. + + :param with_sort_key: if True, include the sort key in + each Category. :param total: iterate no more than this number of pages in total - :param content: if True, retrieve the content of the current version - of each category description page (default False) + :param content: if True, retrieve the content of the current + version of each category description page (default False) :return: a generator that yields Category objects. - :rtype: generator """ - # FIXME: bug T75561: with_sort_key is ignored by Site.pagecategories - if with_sort_key: - raise NotImplementedError('with_sort_key is not implemented') - # Data might have been preloaded # Delete cache if content is needed and elements have no content if hasattr(self, '_categories'): @@ -1772,11 +1762,11 @@ def categories( else: return itertools.islice(self._categories, total) - return self.site.pagecategories(self, total=total, content=content) + return self.site.pagecategories(self, with_sort_key=with_sort_key, + total=total, content=content) def extlinks(self, total: int | None = None) -> Iterable[str]: - """ - Iterate all external URLs (not interwiki links) from this page. + """Iterate all external URLs (not interwiki links) from this page. :param total: iterate no more than this number of pages in total :return: a generator that yields str objects containing URLs. @@ -1784,8 +1774,7 @@ def extlinks(self, total: int | None = None) -> Iterable[str]: return self.site.page_extlinks(self, total=total) def coordinates(self, primary_only: bool = False): - """ - Return a list of Coordinate objects for points on the page. + """Return a list of Coordinate objects for points on the page. Uses the MediaWiki extension GeoData. @@ -1805,8 +1794,7 @@ def coordinates(self, primary_only: bool = False): return list(self._coords) def page_image(self): - """ - Return the most appropriate image on the page. + """Return the most appropriate image on the page. Uses the MediaWiki extension PageImages. @@ -1899,16 +1887,15 @@ def getVersionHistoryTable(self, result += '! oldid || date/time || username || edit summary\n' for entry in self.revisions(reverse=reverse, total=total): result += '|----\n' - result += ('| {r.revid} || {r.timestamp} || {r.user} || ' - '<nowiki>{r.comment}</nowiki>\n'.format(r=entry)) + result += (f'| {entry.revid} || {entry.timestamp} || {entry.user} ' + f'|| <nowiki>{entry.comment}</nowiki>\n') result += '|}\n' return result def contributors(self, total: int | None = None, starttime=None, endtime=None): - """ - Compile contributors of this page with edit counts. + """Compile contributors of this page with edit counts. :param total: iterate no more than this number of revisions in total :param starttime: retrieve revisions starting at this Timestamp @@ -1967,8 +1954,7 @@ def move(self, movetalk: bool = True, noredirect: bool = False, movesubpages: bool = True) -> pywikibot.page.Page: - """ - Move this page to a new title. + """Move this page to a new title. .. versionchanged:: 7.2 The *movesubpages* parameter was added @@ -1997,8 +1983,7 @@ def delete( *, deletetalk: bool = False ) -> int: - """ - Delete the page from the wiki. Requires administrator status. + """Delete the page from the wiki. Requires administrator status. .. versionchanged:: 7.1 keyword only parameter *deletetalk* was added. @@ -2229,8 +2214,7 @@ def change_category(self, old_cat, new_cat, in_place: bool = True, include: list[str] | None = None, show_diff: bool = False) -> bool: - """ - Remove page from oldCat and add it to newCat. + """Remove page from oldCat and add it to newCat. .. versionadded:: 7.0 The `show_diff` parameter @@ -2269,12 +2253,9 @@ def change_category(self, old_cat, new_cat, return False if old_cat not in cats: - if self.namespace() != 10: - pywikibot.error( - f'{self} is not in category {old_cat.title()}!') - else: - pywikibot.info('{} is not in category {}, skipping...' - .format(self, old_cat.title())) + pywikibot.info( + f'{self} is not in category {old_cat.title()}, skipping...' + ) return False # This prevents the bot from adding new_cat if it is already present. diff --git a/pywikibot/page/_category.py b/pywikibot/page/_category.py index 9bda6cd6e4..95b0993ccf 100644 --- a/pywikibot/page/_category.py +++ b/pywikibot/page/_category.py @@ -22,8 +22,7 @@ class Category(Page): """A page in the Category: namespace.""" def __init__(self, source, title: str = '', sort_key=None) -> None: - """ - Initializer. + """Initializer. All parameters are the same as for Page() Initializer. """ @@ -138,8 +137,7 @@ def articles(self, *, recurse: int | bool = False, total: int | None = None, **kwargs: Any) -> Generator[Page, None, None]: - """ - Yield all articles in the current category. + """Yield all articles in the current category. Yields all pages in the category that are not subcategories. Duplicates are filtered. To enable duplicates use :meth:`members` @@ -289,8 +287,7 @@ def newest_pages( self, total: int | None = None ) -> Generator[Page, None, None]: - """ - Return pages in a category ordered by the creation date. + """Return pages in a category ordered by the creation date. If two or more pages are created at the same time, the pages are returned in the order they were added to the category. The most diff --git a/pywikibot/page/_collections.py b/pywikibot/page/_collections.py index ee0edebd6b..5f6822f11b 100644 --- a/pywikibot/page/_collections.py +++ b/pywikibot/page/_collections.py @@ -25,8 +25,7 @@ class BaseDataDict(MutableMapping): - """ - Base structure holding data for a Wikibase entity. + """Base structure holding data for a Wikibase entity. Data are mappings from a language to a value. It will be specialised in subclasses. @@ -82,8 +81,7 @@ def normalizeKey(key) -> str: class LanguageDict(BaseDataDict): - """ - A structure holding language data for a Wikibase entity. + """A structure holding language data for a Wikibase entity. Language data are mappings from a language to a string. It can be labels, descriptions and others. @@ -133,8 +131,7 @@ def toJSON(self, diffto: dict | None = None) -> dict: class AliasesDict(BaseDataDict): - """ - A structure holding aliases for a Wikibase entity. + """A structure holding aliases for a Wikibase entity. It is a mapping from a language to a list of strings. """ @@ -200,6 +197,7 @@ def toJSON(self, diffto: dict | None = None) -> dict: class ClaimCollection(MutableMapping): + """A structure holding claims for a Wikibase entity.""" def __init__(self, repo) -> None: @@ -319,11 +317,11 @@ def set_on_item(self, item) -> None: class SiteLinkCollection(MutableMapping): + """A structure holding SiteLinks for a Wikibase item.""" def __init__(self, repo, data=None) -> None: - """ - Initializer. + """Initializer. :param repo: the Wikibase site on which badges are defined :type repo: pywikibot.site.DataSite @@ -346,8 +344,7 @@ def fromJSON(cls, data, repo): @staticmethod def getdbName(site): - """ - Helper function to obtain a dbName for a Site. + """Helper function to obtain a dbName for a Site. :param site: The site to look up. :type site: pywikibot.site.BaseSite or str @@ -357,8 +354,7 @@ def getdbName(site): return site def __getitem__(self, key): - """ - Get the SiteLink with the given key. + """Get the SiteLink with the given key. :param key: site key as Site instance or db key :type key: pywikibot.Site or str @@ -380,8 +376,7 @@ def __setitem__( key: str | pywikibot.site.APISite, val: str | dict[str, Any] | pywikibot.page.SiteLink, ) -> None: - """ - Set the SiteLink for a given key. + """Set the SiteLink for a given key. This only sets the value given as str, dict or SiteLink. If a str or dict is given the SiteLink object is created later in @@ -424,8 +419,7 @@ def _extract_json(cls, obj): @classmethod def normalizeData(cls, data) -> dict: - """ - Helper function to expand data into the Wikibase API structure. + """Helper function to expand data into the Wikibase API structure. :param data: Data to normalize :type data: list or dict @@ -450,14 +444,13 @@ def normalizeData(cls, data) -> dict: if not isinstance(json, dict): raise ValueError( "Couldn't determine the site and title of the value: " - '{!r}'.format(json)) + f'{json!r}') db_name = json['site'] norm_data[db_name] = json return norm_data def toJSON(self, diffto: dict | None = None) -> dict: - """ - Create JSON suitable for Wikibase API. + """Create JSON suitable for Wikibase API. When diffto is provided, JSON representing differences to the provided data is created. @@ -501,8 +494,7 @@ class SubEntityCollection(MutableSequence): """Ordered collection of sub-entities indexed by their ids.""" def __init__(self, repo, data=None): - """ - Initializer. + """Initializer. :param repo: Wikibase site :type repo: pywikibot.site.DataSite @@ -519,11 +511,10 @@ def __init__(self, repo, data=None): def _validate_isinstance(self, obj): if not isinstance(obj, self.type_class): raise TypeError( - '{} should only hold instances of {}, ' - 'instance of {} was provided' - .format(self.__class__.__name__, - self.type_class.__name__, - obj.__class__.__name__)) + f'{type(self).__name__} should only hold instances of ' + f'{self.type_class.__name__}, instance of ' + f'{type(obj).__name__} was provided' + ) def __getitem__(self, index): if isinstance(index, str): @@ -570,8 +561,7 @@ def fromJSON(cls, data, repo): @classmethod def normalizeData(cls, data: list) -> dict: - """ - Helper function to expand data into the Wikibase API structure. + """Helper function to expand data into the Wikibase API structure. :param data: Data to normalize :type data: list @@ -581,8 +571,7 @@ def normalizeData(cls, data: list) -> dict: raise NotImplementedError # TODO def toJSON(self, diffto: dict | None = None) -> dict: - """ - Create JSON suitable for Wikibase API. + """Create JSON suitable for Wikibase API. When diffto is provided, JSON representing differences to the provided data is created. diff --git a/pywikibot/page/_filepage.py b/pywikibot/page/_filepage.py index d91c1819d2..3e3fd88c21 100644 --- a/pywikibot/page/_filepage.py +++ b/pywikibot/page/_filepage.py @@ -43,9 +43,15 @@ def __init__(self, source, title: str = '', *, """Initializer. .. versionchanged:: 8.4 - check for valid extensions. + Check for valid extensions. .. versionchanged:: 9.3 - *ignore_extension* parameter was added + Added the optional *ignore_extension* parameter. + .. versionchanged:: 9.6 + Show a warning if *ignore_extension* was set and the + extension is invalid. + .. seealso:: + :meth:`Site.file_extensions + <pywikibot.site._apisite.APISite.file_extensions>` :param source: the source of the page :type source: pywikibot.page.BaseLink (or subclass), @@ -62,20 +68,18 @@ def __init__(self, source, title: str = '', *, if self.namespace() != 6: raise ValueError(f"'{self.title()}' is not in the file namespace!") - if ignore_extension: - return - title = self.title(with_ns=False, with_section=False) _, sep, extension = title.rpartition('.') if not sep or extension.lower() not in self.site.file_extensions: - raise ValueError( - f'{title!r} does not have a valid extension ' - f'({", ".join(self.site.file_extensions)}).' - ) + msg = (f'{title!r} does not have a valid extension\n' + f'({", ".join(self.site.file_extensions)}).') + if not ignore_extension: + raise ValueError(msg) + + pywikibot.warning(msg) def _load_file_revisions(self, imageinfo) -> None: - """ - Store an Image revision of FilePage (a FileInfo object) in local cache. + """Save a file revision of FilePage (a FileInfo object) in local cache. Metadata shall be added lazily to the revision already present in cache. @@ -97,8 +101,7 @@ def _load_file_revisions(self, imageinfo) -> None: @property def latest_file_info(self): - """ - Retrieve and store information of latest Image rev. of FilePage. + """Retrieve and store information of latest Image rev. of FilePage. At the same time, the whole history of Image is fetched and cached in self._file_revisions @@ -112,8 +115,7 @@ def latest_file_info(self): @property def oldest_file_info(self): - """ - Retrieve and store information of oldest Image rev. of FilePage. + """Retrieve and store information of oldest Image rev. of FilePage. At the same time, the whole history of Image is fetched and cached in self._file_revisions @@ -126,8 +128,7 @@ def oldest_file_info(self): return self._file_revisions[oldest_ts] def get_file_info(self, ts) -> dict: - """ - Retrieve and store information of a specific Image rev. of FilePage. + """Retrieve and store information of a specific Image rev. of FilePage. This function will load also metadata. It is also used as a helper in FileInfo to load metadata lazily. @@ -142,8 +143,7 @@ def get_file_info(self, ts) -> dict: return self._file_revisions[ts] def get_file_history(self) -> dict: - """ - Return the file's version history. + """Return the file's version history. :return: dictionary with: key: timestamp of the entry @@ -160,8 +160,8 @@ def getImagePageHtml(self) -> str: # noqa: N802 same FilePage object, the page will only be downloaded once. """ if not hasattr(self, '_imagePageHtml'): - path = '{}/index.php?title={}'.format(self.site.scriptpath(), - self.title(as_url=True)) + path = (f'{self.site.scriptpath()}/index.php?' + f'title={self.title(as_url=True)}') self._imagePageHtml = http.request(self.site, path).text return self._imagePageHtml @@ -285,8 +285,7 @@ def file_is_used(self) -> bool: return bool(list(self.using_pages(total=1))) def upload(self, source: str, **kwargs) -> bool: - """ - Upload this file to the wiki. + """Upload this file to the wiki. keyword arguments are from site.upload() method. @@ -425,8 +424,7 @@ def download(self, return False def globalusage(self, total=None): - """ - Iterate all global usage for this page. + """Iterate all global usage for this page. .. seealso:: :meth:`using_pages` @@ -438,8 +436,7 @@ def globalusage(self, total=None): return self.site.globalusage(self, total=total) def data_item(self): - """ - Convenience function to get the associated Wikibase item of the file. + """Function to get the associated Wikibase item of the file. If WikibaseMediaInfo extension is available (e.g., on Commons), the method returns the associated mediainfo entity. Otherwise, @@ -460,8 +457,7 @@ def data_item(self): class FileInfo: - """ - A structure holding imageinfo of latest rev. of FilePage. + """A structure holding imageinfo of latest rev. of FilePage. All keys of API imageinfo dictionary are mapped to FileInfo attributes. Attributes can be retrieved both as self['key'] or self.key. diff --git a/pywikibot/page/_links.py b/pywikibot/page/_links.py index 4373132d06..bf5e323c58 100644 --- a/pywikibot/page/_links.py +++ b/pywikibot/page/_links.py @@ -6,7 +6,7 @@ its contents. """ # -# (C) Pywikibot team, 2008-2023 +# (C) Pywikibot team, 2008-2024 # # Distributed under the terms of the MIT license. # @@ -34,8 +34,7 @@ class BaseLink(ComparableMixin): - """ - A MediaWiki link (local or interwiki). + """A MediaWiki link (local or interwiki). Has the following attributes: @@ -49,8 +48,7 @@ class BaseLink(ComparableMixin): _items = ('title', 'namespace', '_sitekey') def __init__(self, title: str, namespace=None, site=None) -> None: - """ - Initializer. + """Initializer. :param title: the title of the page linked to (str); does not include namespace or section @@ -86,8 +84,7 @@ def __repr__(self) -> str: return f"pywikibot.page.{type(self).__name__}({', '.join(attrs)})" def lookup_namespace(self): - """ - Look up the namespace given the provided namespace id or name. + """Look up the namespace given the provided namespace id or name. :rtype: pywikibot.Namespace """ @@ -112,8 +109,7 @@ def lookup_namespace(self): @property def site(self): - """ - Return the site of the link. + """Return the site of the link. :rtype: pywikibot.Site """ @@ -123,8 +119,7 @@ def site(self): @property def namespace(self): - """ - Return the namespace of the link. + """Return the namespace of the link. :rtype: pywikibot.Namespace """ @@ -140,8 +135,7 @@ def canonical_title(self) -> str: return self.title def ns_title(self, onsite=None): - """ - Return full page title, including namespace. + """Return full page title, including namespace. :param onsite: site object if specified, present title using onsite local namespace, @@ -161,16 +155,16 @@ def ns_title(self, onsite=None): break else: raise InvalidTitleError( - 'No corresponding title found for namespace {} on {}.' - .format(self.namespace, onsite)) + 'No corresponding title found for namespace ' + f'{self.namespace} on {onsite}.' + ) if self.namespace != Namespace.MAIN: return f'{name}:{self.title}' return self.title def astext(self, onsite=None) -> str: - """ - Return a text representation of the link. + """Return a text representation of the link. :param onsite: if specified, present as a (possibly interwiki) link from the given site; otherwise, present as an internal link on @@ -192,8 +186,7 @@ def astext(self, onsite=None) -> str: return f'[[{self.site.sitename}:{title}]]' def _cmpkey(self): - """ - Key for comparison of BaseLink objects. + """Key for comparison of BaseLink objects. BaseLink objects are "equal" if and only if they are on the same site and have the same normalized title. @@ -212,8 +205,7 @@ def __hash__(self): @classmethod def fromPage(cls, page): # noqa: N802 - """ - Create a BaseLink to a Page. + """Create a BaseLink to a Page. :param page: target pywikibot.page.Page :type page: pywikibot.page.Page @@ -229,8 +221,7 @@ def fromPage(cls, page): # noqa: N802 class Link(BaseLink): - """ - A MediaWiki wikitext link (local or interwiki). + """A MediaWiki wikitext link (local or interwiki). Constructs a Link object based on a wikitext link and a source site. @@ -258,8 +249,7 @@ class Link(BaseLink): ) def __init__(self, text, source=None, default_namespace=0) -> None: - """ - Initializer. + """Initializer. :param text: the link text (everything appearing between [[ and ]] on a wiki page) @@ -319,9 +309,10 @@ def __init__(self, text, source=None, default_namespace=0) -> None: # Cleanup whitespace sep = self._source.family.title_delimiter_and_aliases[0] t = re.sub( - '[{}\xa0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+' - .format(self._source.family.title_delimiter_and_aliases), - sep, t) + f'[{self._source.family.title_delimiter_and_aliases}' + '\xa0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+', + sep, t + ) # Strip spaces at both ends t = t.strip() # Remove left-to-right and right-to-left markers. @@ -332,8 +323,7 @@ def __init__(self, text, source=None, default_namespace=0) -> None: self._text = source.title(with_section=False) + self._text def parse_site(self) -> tuple: - """ - Parse only enough text to determine which site the link points to. + """Parse only enough text to determine which site the link points to. This method does not parse anything after the first ":"; links with multiple interwiki prefixes (such as "wikt:fr:Parlais") need @@ -372,8 +362,7 @@ def parse_site(self) -> tuple: return (fam.name, code) # text before : doesn't match any known prefix def parse(self): - """ - Parse wikitext of the link. + """Parse wikitext of the link. Called internally when accessing attributes. """ @@ -408,16 +397,18 @@ def parse(self): break # text before : doesn't match any known prefix except SiteDefinitionError as e: raise SiteDefinitionError( - '{} is not a local page on {}, and the interwiki ' - 'prefix {} is not supported by Pywikibot!\n{}' - .format(self._text, self._site, prefix, e)) + f'{self._text} is not a local page on {self._site}, and ' + f'the interwiki prefix {prefix} is not supported by ' + f'Pywikibot!\n{e}' + ) else: if first_other_site: if not self._site.local_interwiki(prefix): raise InvalidTitleError( - '{} links to a non local site {} via an ' - 'interwiki link to {}.'.format( - self._text, newsite, first_other_site)) + f'{self._text} links to a non local site ' + f'{newsite} via an interwiki link to ' + f'{first_other_site}.' + ) elif newsite != self._source: first_other_site = newsite self._site = newsite @@ -448,8 +439,9 @@ def parse(self): next_ns = t[:t.index(':')] if self._site.namespaces.lookup_name(next_ns): raise InvalidTitleError( - "The (non-)talk page of '{}' is a valid title " - 'in another namespace.'.format(self._text)) + f"The (non-)talk page of '{self._text}' is a valid" + ' title in another namespace.' + ) # Reject illegal characters. m = Link.illegal_titles_pattern.search(t) @@ -493,8 +485,7 @@ def parse(self): @property def site(self): - """ - Return the site of the link. + """Return the site of the link. :rtype: pywikibot.Site """ @@ -504,8 +495,7 @@ def site(self): @property def namespace(self): - """ - Return the namespace of the link. + """Return the namespace of the link. :rtype: pywikibot.Namespace """ @@ -535,8 +525,7 @@ def anchor(self) -> str: return self._anchor def astext(self, onsite=None): - """ - Return a text representation of the link. + """Return a text representation of the link. :param onsite: if specified, present as a (possibly interwiki) link from the given site; otherwise, present as an internal link on @@ -551,8 +540,7 @@ def astext(self, onsite=None): return text def _cmpkey(self): - """ - Key for comparison of Link objects. + """Key for comparison of Link objects. Link objects are "equal" if and only if they are on the same site and have the same normalized title, including section if any. @@ -563,8 +551,7 @@ def _cmpkey(self): @classmethod def fromPage(cls, page, source=None): # noqa: N802 - """ - Create a Link to a Page. + """Create a Link to a Page. :param page: target Page :type page: pywikibot.page.Page @@ -586,18 +573,15 @@ def fromPage(cls, page, source=None): # noqa: N802 return link @classmethod - def langlinkUnsafe(cls, lang, title, source): # noqa: N802 - """ - Create a "lang:title" Link linked from source. + def langlinkUnsafe(cls, lang: str, title: str, source): # noqa: N802 + """Create a "lang:title" Link linked from source. Assumes that the lang & title come clean, no checks are made. :param lang: target site code (language) - :type lang: str :param title: target Page - :type title: str :param source: Link from site source - :param source: Site + :type source: Site :rtype: pywikibot.page.Link """ @@ -628,8 +612,7 @@ def langlinkUnsafe(cls, lang, title, source): # noqa: N802 @classmethod def create_separated(cls, link, source, default_namespace=0, section=None, label=None): - """ - Create a new instance but overwrite section or label. + """Create a new instance but overwrite section or label. The returned Link instance is already parsed. @@ -661,8 +644,7 @@ def create_separated(cls, link, source, default_namespace=0, section=None, class SiteLink(BaseLink): - """ - A single sitelink in a Wikibase item. + """A single sitelink in a Wikibase item. Extends BaseLink by the following attribute: @@ -675,8 +657,7 @@ class SiteLink(BaseLink): _items = ('_sitekey', '_rawtitle', 'badges') def __init__(self, title, site=None, badges=None) -> None: - """ - Initializer. + """Initializer. :param title: the title of the linked page including namespace :type title: str @@ -699,8 +680,7 @@ def __init__(self, title, site=None, badges=None) -> None: @staticmethod def _parse_namespace(title, site=None): - """ - Parse enough of a title with a ':' to determine the namespace. + """Parse enough of a title with a ':' to determine the namespace. :param site: the Site object for the wiki linked to. Can be provided as either a Site instance or a db key, defaults to pywikibot.Site(). @@ -727,8 +707,7 @@ def _parse_namespace(title, site=None): @property def badges(self): - """ - Return a list of all badges associated with the link. + """Return a list of all badges associated with the link. :rtype: [pywikibot.ItemPage] """ @@ -740,8 +719,7 @@ def fromJSON( # noqa: N802 data: dict[str, Any], site: pywikibot.site.DataSite | None = None, ) -> SiteLink: - """ - Create a SiteLink object from JSON returned in the API call. + """Create a SiteLink object from JSON returned in the API call. :param data: JSON containing SiteLink data :param site: The Wikibase site @@ -754,8 +732,7 @@ def fromJSON( # noqa: N802 return sl def toJSON(self) -> dict[str, str | list[str]]: # noqa: N802 - """ - Convert the SiteLink to a JSON object for the Wikibase API. + """Convert the SiteLink to a JSON object for the Wikibase API. :return: Wikibase JSON """ @@ -806,12 +783,12 @@ def toJSON(self) -> dict[str, str | list[str]]: # noqa: N802 } -def html2unicode(text: str, ignore=None, exceptions=None) -> str: - """ - Replace HTML entities with equivalent unicode. +def html2unicode(text: str, + ignore: list[int] | None = None, + exceptions=None) -> str: + """Replace HTML entities with equivalent unicode. :param ignore: HTML entities to ignore - :param ignore: list of int """ if ignore is None: ignore = [] diff --git a/pywikibot/page/_page.py b/pywikibot/page/_page.py index 991a98563b..6e5f277b39 100644 --- a/pywikibot/page/_page.py +++ b/pywikibot/page/_page.py @@ -112,13 +112,12 @@ def templatesWithParams( # noqa: N802 positional.append(intkeys[i]) continue - for k in intkeys: + for k, v in intkeys.items(): if k < 1 or k >= i: - named[str(k)] = intkeys[k] + named[str(k)] = v break - for item in named.items(): - positional.append('{}={}'.format(*item)) + positional += [f'{key}={value}' for key, value in named.items()] result.append((pywikibot.Page(link, self.site), positional)) return result @@ -184,8 +183,7 @@ def set_redirect_target( self.save(**kwargs) def get_best_claim(self, prop: str): - """ - Return the first best Claim for this page. + """Return the first best Claim for this page. Return the first 'preferred' ranked Claim specified by Wikibase property or the first 'normal' one otherwise. diff --git a/pywikibot/page/_revision.py b/pywikibot/page/_revision.py index 27d077cc2f..8d9075cd68 100644 --- a/pywikibot/page/_revision.py +++ b/pywikibot/page/_revision.py @@ -1,6 +1,6 @@ """Object representing page revision.""" # -# (C) Pywikibot team, 2008-2022 +# (C) Pywikibot team, 2008-2024 # # Distributed under the terms of the MIT license. # @@ -95,5 +95,6 @@ def __str__(self) -> str: def __missing__(self, key): """Provide backward compatibility for exceptions.""" # raise AttributeError instead of KeyError for backward compatibility - raise AttributeError("'{}' object has no attribute '{}'" - .format(self.__class__.__name__, key)) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{key}'" + ) diff --git a/pywikibot/page/_toolforge.py b/pywikibot/page/_toolforge.py index 29b22e9595..19a2782c67 100644 --- a/pywikibot/page/_toolforge.py +++ b/pywikibot/page/_toolforge.py @@ -11,6 +11,7 @@ import collections import re +from http import HTTPStatus from typing import TYPE_CHECKING import pywikibot @@ -50,9 +51,8 @@ def _check_wh_supported(self): 'main_authors method is implemented for wikipedia family only') if self.site.code not in self.WIKIBLAME_CODES: - raise NotImplementedError( - 'main_authors method is not implemented for wikipedia:{}' - .format(self.site.code)) + raise NotImplementedError('main_authors method is not implemented ' + f'for wikipedia:{self.site.code}') if self.namespace() != pywikibot.site.Namespace.MAIN: raise NotImplementedError( @@ -184,7 +184,7 @@ def authorship( url = baseurl.format(url=url) r = pywikibot.comms.http.fetch(url) - if r.status_code != 200: + if r.status_code != HTTPStatus.OK: r.raise_for_status() result: list[list[str]] = [] diff --git a/pywikibot/page/_user.py b/pywikibot/page/_user.py index 01ed5e6194..49e76e80b5 100644 --- a/pywikibot/page/_user.py +++ b/pywikibot/page/_user.py @@ -26,15 +26,13 @@ class User(Page): - """ - A class that represents a Wiki user. + """A class that represents a Wiki user. This class also represents the Wiki page User:<username> """ def __init__(self, source, title: str = '') -> None: - """ - Initializer for a User object. + """Initializer for a User object. All parameters are the same as for Page() Initializer. """ @@ -56,8 +54,7 @@ def __init__(self, source, title: str = '') -> None: @property def username(self) -> str: - """ - The username. + """The username. Convenience method that returns the title of the page with namespace prefix omitted, which is the username. @@ -67,8 +64,7 @@ def username(self) -> str: return self.title(with_ns=False) def isRegistered(self, force: bool = False) -> bool: # noqa: N802 - """ - Determine if the user is registered on the site. + """Determine if the user is registered on the site. It is possible to have a page named User:xyz and not have a corresponding user with username xyz. @@ -91,8 +87,7 @@ def is_CIDR(self) -> bool: # noqa: N802 return is_ip_network(self.username) def getprops(self, force: bool = False) -> dict: - """ - Return a properties about the user. + """Return a properties about the user. :param force: if True, forces reloading the data from API """ @@ -109,8 +104,7 @@ def getprops(self, force: bool = False) -> dict: def registration(self, force: bool = False) -> pywikibot.Timestamp | None: - """ - Fetch registration date for this user. + """Fetch registration date for this user. :param force: if True, forces reloading the data from API """ @@ -121,8 +115,7 @@ def registration(self, return None def editCount(self, force: bool = False) -> int: # noqa: N802 - """ - Return edit count for a registered user. + """Return edit count for a registered user. Always returns 0 for 'anonymous' users. @@ -162,16 +155,14 @@ def is_locked(self, force: bool = False) -> bool: return self.site.is_locked(self.username, force) def isEmailable(self, force: bool = False) -> bool: # noqa: N802 - """ - Determine whether emails may be send to this user through MediaWiki. + """Determine whether emails may be send to this user through MediaWiki. :param force: if True, forces reloading the data from API """ return not self.isAnonymous() and 'emailable' in self.getprops(force) def groups(self, force: bool = False) -> list: - """ - Return a list of groups to which this user belongs. + """Return a list of groups to which this user belongs. The list of groups may be empty. @@ -199,8 +190,7 @@ def rights(self, force: bool = False) -> list: return self.getprops(force).get('rights', []) def getUserPage(self, subpage: str = '') -> Page: # noqa: N802 - """ - Return a Page object relative to this user's main page. + """Return a Page object relative to this user's main page. :param subpage: subpage part to be appended to the main page title (optional) @@ -216,8 +206,7 @@ def getUserPage(self, subpage: str = '') -> Page: # noqa: N802 return Page(Link(self.title() + subpage, self.site)) def getUserTalkPage(self, subpage: str = '') -> Page: # noqa: N802 - """ - Return a Page object relative to this user's main talk page. + """Return a Page object relative to this user's main talk page. :param subpage: subpage part to be appended to the main talk page title (optional) @@ -234,8 +223,7 @@ def getUserTalkPage(self, subpage: str = '') -> Page: # noqa: N802 self.site, default_namespace=3)) def send_email(self, subject: str, text: str, ccme: bool = False) -> bool: - """ - Send an email to this user via MediaWiki's email interface. + """Send an email to this user via MediaWiki's email interface. :param subject: the subject header of the mail :param text: mail body @@ -266,8 +254,7 @@ def send_email(self, subject: str, text: str, ccme: bool = False) -> bool: and maildata['emailuser']['result'] == 'Success') def block(self, *args, **kwargs): - """ - Block user. + """Block user. Refer :py:obj:`APISite.blockuser` method for parameters. @@ -282,8 +269,7 @@ def block(self, *args, **kwargs): raise def unblock(self, reason: str | None = None) -> None: - """ - Remove the block for the user. + """Remove the block for the user. :param reason: Reason for the unblock. """ @@ -424,8 +410,7 @@ def deleted_contributions( yield page, Revision(**contrib) def uploadedImages(self, total: int = 10): # noqa: N802 - """ - Yield tuples describing files uploaded by this user. + """Yield tuples describing files uploaded by this user. Each tuple is composed of a pywikibot.Page, the timestamp (str in ISO8601 format), comment (str) and a bool for pageid > 0. @@ -443,8 +428,7 @@ def uploadedImages(self, total: int = 10): # noqa: N802 @property def is_thankable(self) -> bool: - """ - Determine if the user has thanks notifications enabled. + """Determine if the user has thanks notifications enabled. .. note:: This doesn't accurately determine if thanks is enabled for user. diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py index 7382c3c12c..4324705877 100644 --- a/pywikibot/page/_wikibase.py +++ b/pywikibot/page/_wikibase.py @@ -1,5 +1,4 @@ -""" -Objects representing various types of Wikibase pages and structures. +"""Objects representing various types of Wikibase pages and structures. This module also includes objects: @@ -78,8 +77,7 @@ class WikibaseEntity: - """ - The base interface for Wikibase entities. + """The base interface for Wikibase entities. Each entity is identified by a data repository it belongs to and an identifier. @@ -100,8 +98,7 @@ class WikibaseEntity: DATA_ATTRIBUTES: dict[str, Any] = {} def __init__(self, repo, id_: str | None = None) -> None: - """ - Initializer. + """Initializer. :param repo: Entity repository. :type repo: DataSite @@ -116,14 +113,13 @@ def __init__(self, repo, id_: str | None = None) -> None: def __repr__(self) -> str: if self.id != '-1': - return 'pywikibot.page.{}({!r}, {!r})'.format( - self.__class__.__name__, self.repo, self.id) + return (f'pywikibot.page.{type(self).__name__}' + f'({self.repo!r}, {self.id!r})') return f'pywikibot.page.{self.__class__.__name__}({self.repo!r})' @classmethod def is_valid_id(cls, entity_id: str) -> bool: - """ - Whether the string can be a valid id of the entity type. + """Whether the string can be a valid id of the entity type. :param entity_id: The ID to test. """ @@ -139,16 +135,16 @@ def __getattr__(self, name): return getattr(self, name) return self.get()[name] - raise AttributeError("'{}' object has no attribute '{}'" - .format(self.__class__.__name__, name)) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) def _initialize_empty(self): for key, cls in self.DATA_ATTRIBUTES.items(): setattr(self, key, cls.new_empty(self.repo)) def _defined_by(self, singular: bool = False) -> dict[str, str]: - """ - Internal function to provide the API parameters to identify the entity. + """Function to provide the API parameters to identify the entity. An empty dict is returned if the entity has not been created yet. @@ -165,8 +161,7 @@ def _defined_by(self, singular: bool = False) -> dict[str, str]: return params def getID(self, numeric: bool = False) -> int | str: - """ - Get the identifier of this entity. + """Get the identifier of this entity. :param numeric: Strip the first letter and return an int """ @@ -175,16 +170,14 @@ def getID(self, numeric: bool = False) -> int | str: return self.id def get_data_for_new_entity(self) -> dict: - """ - Return data required for creation of a new entity. + """Return data required for creation of a new entity. Override it if you need. """ return {} def toJSON(self, diffto: dict | None = None) -> dict: - """ - Create JSON suitable for Wikibase API. + """Create JSON suitable for Wikibase API. When diffto is provided, JSON representing differences to the provided data is created. @@ -206,8 +199,7 @@ def toJSON(self, diffto: dict | None = None) -> dict: @classmethod def _normalizeData(cls, data: dict) -> dict: - """ - Helper function to expand data into the Wikibase API structure. + """Helper function to expand data into the Wikibase API structure. :param data: The dict to normalize :return: The dict with normalized data @@ -220,8 +212,7 @@ def _normalizeData(cls, data: dict) -> dict: @property def latest_revision_id(self) -> int | None: - """ - Get the revision identifier for the most recent revision of the entity. + """Get the revision id for the most recent revision of the entity. :rtype: int or None if it cannot be determined :raise NoWikibaseEntityError: if the entity doesn't exist @@ -252,8 +243,7 @@ def exists(self) -> bool: return 'missing' not in self._content def get(self, force: bool = False) -> dict: - """ - Fetch all entity data and cache it. + """Fetch all entity data and cache it. :param force: override caching :raise NoWikibaseEntityError: if this entity doesn't exist @@ -360,8 +350,7 @@ def editEntity( target_ref.hash = ref_stat['hash'] def concept_uri(self) -> str: - """ - Return the full concept URI. + """Return the full concept URI. :raise NoWikibaseEntityError: if this entity's id is not known """ @@ -408,8 +397,7 @@ def _assert_has_id(self): self.id = 'M' + str(self.file.pageid) def _defined_by(self, singular: bool = False) -> dict: - """ - Internal function to provide the API parameters to identify the entity. + """Function to provide the API parameters to identify the entity. .. versionadded:: 8.5 @@ -591,8 +579,7 @@ def removeClaims(self, claims, **kwargs) -> None: class WikibasePage(BasePage, WikibaseEntity): - """ - Mixin base class for Wikibase entities which are also pages (eg. items). + """Mixin base class for Wikibase entities which are also pages (eg. items). There should be no need to instantiate this directly. """ @@ -600,8 +587,7 @@ class WikibasePage(BasePage, WikibaseEntity): _cache_attrs = (*BasePage._cache_attrs, '_content') def __init__(self, site, title: str = '', **kwargs) -> None: - """ - Initializer. + """Initializer. If title is provided, either ns or entity_type must also be provided, and will be checked against the title parsed using the Page @@ -624,9 +610,8 @@ def __init__(self, site, title: str = '', **kwargs) -> None: if not isinstance(site, pywikibot.site.DataSite): raise TypeError('site must be a pywikibot.site.DataSite object') if title and ('ns' not in kwargs and 'entity_type' not in kwargs): - pywikibot.debug('{}.__init__: {} title {!r} specified without ' - 'ns or entity_type' - .format(type(self).__name__, site, title)) + pywikibot.debug(f'{type(self).__name__}.__init__: {site} title ' + f'{title!r} specified without ns or entity_type') self._namespace = None @@ -673,8 +658,9 @@ def __init__(self, site, title: str = '', **kwargs) -> None: if self._namespace: if self._link.namespace != self._namespace.id: - raise ValueError("'{}' is not in the namespace {}" - .format(title, self._namespace.id)) + raise ValueError( + f"'{title}' is not in the namespace {self._namespace.id}" + ) else: # Neither ns or entity_type was provided. # Use the _link to determine entity type. @@ -697,8 +683,7 @@ def __init__(self, site, title: str = '', **kwargs) -> None: self._link.title) def namespace(self) -> int: - """ - Return the number of the namespace of the entity. + """Return the number of the namespace of the entity. :return: Namespace id """ @@ -715,8 +700,7 @@ def exists(self) -> bool: return 'missing' not in self._content def botMayEdit(self) -> bool: - """ - Return whether bots may edit this page. + """Return whether bots may edit this page. Because there is currently no system to mark a page that it shouldn't be edited by bots on Wikibase pages it always returns True. The content @@ -728,8 +712,7 @@ def botMayEdit(self) -> bool: return True def get(self, force: bool = False, *args, **kwargs) -> dict: - """ - Fetch all page data, and cache it. + """Fetch all page data, and cache it. :param force: override caching :raise NotImplementedError: a value in args or kwargs @@ -741,10 +724,11 @@ def get(self, force: bool = False, *args, **kwargs) -> dict: """ if args or kwargs: raise NotImplementedError( - '{}.get does not implement var args: {!r} and {!r}'.format( - self.__class__.__name__, args, kwargs)) + f'{type(self).__name__}.get does not implement var args: ' + f'{args!r} and {kwargs!r}' + ) - # todo: this variable is specific to ItemPage + # TODO: this variable is specific to ItemPage lazy_loading_id = not hasattr(self, 'id') and hasattr(self, '_site') try: data = WikibaseEntity.get(self, force=force) @@ -753,7 +737,7 @@ def get(self, force: bool = False, *args, **kwargs) -> dict: p = pywikibot.Page(self._site, self._title) if not p.exists(): raise NoPageError(p) - # todo: raise a nicer exception here (T87345) + # TODO: raise a nicer exception here (T87345) raise NoPageError(self) if 'pageid' in self._content: @@ -763,8 +747,7 @@ def get(self, force: bool = False, *args, **kwargs) -> dict: @property def latest_revision_id(self) -> int: - """ - Get the revision identifier for the most recent revision of the entity. + """Get the revision id for the most recent revision of the entity. :rtype: int :raise pywikibot.exceptions.NoPageError: if the entity doesn't exist @@ -886,8 +869,7 @@ def set_redirect_target( save: bool = True, **kwargs ): - """ - Set target of a redirect for a Wikibase page. + """Set target of a redirect for a Wikibase page. Has not been implemented in the Wikibase API yet, except for ItemPage. """ @@ -895,8 +877,7 @@ def set_redirect_target( @allow_asynchronous def addClaim(self, claim, bot: bool = True, **kwargs): - """ - Add a claim to the entity. + """Add a claim to the entity. :param claim: The claim to add :type claim: pywikibot.page.Claim @@ -919,8 +900,7 @@ def addClaim(self, claim, bot: bool = True, **kwargs): claim.on_item = self def removeClaims(self, claims, **kwargs) -> None: - """ - Remove the claims from the entity. + """Remove the claims from the entity. :param claims: list of claims to be removed :type claims: list or pywikibot.Claim @@ -960,8 +940,7 @@ class ItemPage(WikibasePage): } def __init__(self, site, title=None, ns=None) -> None: - """ - Initializer. + """Initializer. :param site: data repository :type site: pywikibot.site.DataSite @@ -989,8 +968,7 @@ def __init__(self, site, title=None, ns=None) -> None: assert self.id == self._link.title def _defined_by(self, singular: bool = False) -> dict: - """ - Internal function to provide the API parameters to identify the item. + """Function to provide the API parameters to identify the item. The API parameters may be 'id' if the ItemPage has one, or 'site'&'title' if instantiated via ItemPage.fromPage with @@ -1036,8 +1014,7 @@ def _defined_by(self, singular: bool = False) -> dict: return params def title(self, **kwargs): - """ - Return ID as title of the ItemPage. + """Return ID as title of the ItemPage. If the ItemPage was lazy-loaded via ItemPage.fromPage, this method will fetch the Wikibase item ID for the page, potentially raising @@ -1070,8 +1047,7 @@ def title(self, **kwargs): return super().title(**kwargs) def getID(self, numeric: bool = False, force: bool = False): - """ - Get the entity identifier. + """Get the entity identifier. :param numeric: Strip the first letter and return an int :param force: Force an update of new data @@ -1082,8 +1058,7 @@ def getID(self, numeric: bool = False, force: bool = False): @classmethod def fromPage(cls, page, lazy_load: bool = False): - """ - Get the ItemPage for a Page that links to it. + """Get the ItemPage for a Page that links to it. :param page: Page to look for corresponding data item :type page: pywikibot.page.Page @@ -1122,8 +1097,7 @@ def fromPage(cls, page, lazy_load: bool = False): @classmethod def from_entity_uri(cls, site, uri: str, lazy_load: bool = False): - """ - Get the ItemPage from its entity uri. + """Get the ItemPage from its entity uri. :param site: The Wikibase site for the item. :type site: pywikibot.site.DataSite @@ -1160,8 +1134,7 @@ def get( *args, **kwargs ) -> dict[str, Any]: - """ - Fetch all item data, and cache it. + """Fetch all item data, and cache it. :param force: override caching :param get_redirect: return the item content, do not follow the @@ -1211,8 +1184,7 @@ def getRedirectTarget(self, *, ignore_section: bool = True): return self.__class__(target.site, target.title(), target.namespace()) def iterlinks(self, family=None): - """ - Iterate through all the sitelinks. + """Iterate through all the sitelinks. :param family: string/Family object which represents what family of links to iterate @@ -1242,8 +1214,6 @@ def getSitelink(self, site, force: bool = False) -> str: :param site: Site to find the linked page of. :type site: pywikibot.Site or database name :param force: override caching - :param get_redirect: return the item content, do not follow the - redirect, do not raise an exception. :raise IsRedirectPageError: instance is a redirect page :raise NoSiteLinkError: site is not in :attr:`sitelinks` """ @@ -1269,8 +1239,7 @@ def setSitelink(self, sitelink: SITELINK_TYPE, **kwargs) -> None: self.setSitelinks([sitelink], **kwargs) def removeSitelink(self, site: LANGUAGE_IDENTIFIER, **kwargs) -> None: - """ - Remove a sitelink. + """Remove a sitelink. A site can either be a Site object, or it can be a dbName. """ @@ -1278,8 +1247,7 @@ def removeSitelink(self, site: LANGUAGE_IDENTIFIER, **kwargs) -> None: def removeSitelinks(self, sites: list[LANGUAGE_IDENTIFIER], **kwargs ) -> None: - """ - Remove sitelinks. + """Remove sitelinks. Sites should be a list, with values either being Site objects, or dbNames. @@ -1303,8 +1271,7 @@ def setSitelinks(self, sitelinks: list[SITELINK_TYPE], **kwargs) -> None: self.editEntity(data, **kwargs) def mergeInto(self, item, **kwargs) -> None: - """ - Merge the item into another item. + """Merge the item into another item. :param item: The item to merge into :type item: pywikibot.page.ItemPage @@ -1370,8 +1337,7 @@ def isRedirectPage(self): class Property: - """ - A Wikibase property. + """A Wikibase property. While every Wikibase property has a Page on the data repository, this object is for when the property is used as part of another concept @@ -1420,8 +1386,7 @@ class Property: } def __init__(self, site, id: str, datatype: str | None = None) -> None: - """ - Initializer. + """Initializer. :param site: data repository :type site: pywikibot.site.DataSite @@ -1459,8 +1424,7 @@ def type(self) -> str: return self.repo.get_property_type(self) def getID(self, numeric: bool = False): - """ - Get the identifier of this property. + """Get the identifier of this property. :param numeric: Strip the first letter and return an int """ @@ -1494,8 +1458,7 @@ class PropertyPage(WikibasePage, Property): } def __init__(self, source, title=None, datatype=None) -> None: - """ - Initializer. + """Initializer. :param source: data repository property is on :type source: pywikibot.site.DataSite @@ -1523,8 +1486,7 @@ def __init__(self, source, title=None, datatype=None) -> None: Property.__init__(self, source, self.id) def get(self, force: bool = False, *args, **kwargs) -> dict: - """ - Fetch the property entity, and cache it. + """Fetch the property entity, and cache it. :param force: override caching :raise NotImplementedError: a value in args or kwargs @@ -1547,13 +1509,12 @@ def get(self, force: bool = False, *args, **kwargs) -> dict: def newClaim(self, *args, **kwargs) -> Claim: """Helper function to create a new claim object for this property.""" - # todo: raise when self.id is -1 + # TODO: raise when self.id is -1 return Claim(self.site, self.getID(), *args, datatype=self.type, **kwargs) def getID(self, numeric: bool = False): - """ - Get the identifier of this property. + """Get the identifier of this property. :param numeric: Strip the first letter and return an int """ @@ -1571,8 +1532,7 @@ def get_data_for_new_entity(self): class Claim(Property): - """ - A Claim on a Wikibase entity. + """A Claim on a Wikibase entity. Claims are standard claims as well as references and qualifiers. """ @@ -1614,8 +1574,7 @@ def __init__( rank: str = 'normal', **kwargs ) -> None: - """ - Initializer. + """Initializer. Defined by the "snak" value, supplemented by site + pid @@ -1657,8 +1616,8 @@ def on_item(self, item) -> None: qualifier.on_item = item for source in self.sources: for values in source.values(): - for source in values: - source.on_item = item + for val in values: + val.on_item = item def _assert_attached(self) -> None: if self.on_item is None: @@ -1733,8 +1692,7 @@ def same_as( return True def copy(self): - """ - Create an independent copy of this object. + """Create an independent copy of this object. :rtype: pywikibot.page.Claim """ @@ -1806,8 +1764,7 @@ def fromJSON(cls, site, data: dict[str, Any]) -> Claim: @classmethod def referenceFromJSON(cls, site, data) -> dict: - """ - Create a dict of claims from reference JSON returned in the API call. + """Create a dict of claims from reference JSON fetched in the API call. Reference objects are represented a bit differently, and require some more handling. @@ -1830,8 +1787,7 @@ def referenceFromJSON(cls, site, data) -> dict: @classmethod def qualifierFromJSON(cls, site, data): - """ - Create a Claim for a qualifier from JSON. + """Create a Claim for a qualifier from JSON. Qualifier objects are represented a bit differently like references, but I'm not @@ -1892,8 +1848,7 @@ def toJSON(self) -> dict: return data def setTarget(self, value): - """ - Set the target value in the local object. + """Set the target value in the local object. :param value: The new target value. :type value: object @@ -1912,8 +1867,7 @@ def changeTarget( snaktype: str = 'value', **kwargs ) -> None: - """ - Set the target value in the data repository. + """Set the target value in the data repository. :param value: The new target value. :type value: object @@ -1931,8 +1885,7 @@ def changeTarget( self.on_item.latest_revision_id = data['pageinfo']['lastrevid'] def getTarget(self): - """ - Return the target value of this Claim. + """Return the target value of this Claim. None is returned if no target is set @@ -1941,16 +1894,14 @@ def getTarget(self): return self.target def getSnakType(self) -> str: - """ - Return the type of snak. + """Return the type of snak. :return: str ('value', 'somevalue' or 'novalue') """ return self.snaktype def setSnakType(self, value): - """ - Set the type of snak. + """Set the type of snak. :param value: Type of snak :type value: str ('value', 'somevalue', or 'novalue') @@ -1978,8 +1929,7 @@ def changeRank(self, rank, **kwargs): return self.on_item.repo.save_claim(self, **kwargs) def changeSnakType(self, value=None, **kwargs) -> None: - """ - Save the new snak value. + """Save the new snak value. TODO: Is this function really needed? """ @@ -1992,8 +1942,7 @@ def getSources(self) -> list: return self.sources def addSource(self, claim, **kwargs) -> None: - """ - Add the claim as a source. + """Add the claim as a source. :param claim: the claim to add :type claim: pywikibot.Claim @@ -2001,8 +1950,7 @@ def addSource(self, claim, **kwargs) -> None: self.addSources([claim], **kwargs) def addSources(self, claims, **kwargs): - """ - Add the claims as one source. + """Add the claims as one source. :param claims: the claims to add :type claims: list of pywikibot.Claim @@ -2026,8 +1974,7 @@ def addSources(self, claims, **kwargs): self.sources.append(source) def removeSource(self, source, **kwargs) -> None: - """ - Remove the source. Call removeSources(). + """Remove the source. Call removeSources(). :param source: the source to remove :type source: pywikibot.Claim @@ -2035,8 +1982,7 @@ def removeSource(self, source, **kwargs) -> None: self.removeSources([source], **kwargs) def removeSources(self, sources, **kwargs) -> None: - """ - Remove the sources. + """Remove the sources. :param sources: the sources to remove :type sources: list of pywikibot.Claim @@ -2071,8 +2017,7 @@ def addQualifier(self, qualifier, **kwargs): self.qualifiers[qualifier.getID()] = [qualifier] def removeQualifier(self, qualifier, **kwargs) -> None: - """ - Remove the qualifier. Call removeQualifiers(). + """Remove the qualifier. Call removeQualifiers(). :param qualifier: the qualifier to remove :type qualifier: pywikibot.page.Claim @@ -2080,8 +2025,7 @@ def removeQualifier(self, qualifier, **kwargs) -> None: self.removeQualifiers([qualifier], **kwargs) def removeQualifiers(self, qualifiers, **kwargs) -> None: - """ - Remove the qualifiers. + """Remove the qualifiers. :param qualifiers: the qualifiers to remove :type qualifiers: list of pywikibot.Claim @@ -2095,8 +2039,7 @@ def removeQualifiers(self, qualifiers, **kwargs) -> None: qualifier.on_item = None def target_equals(self, value) -> bool: - """ - Check whether the Claim's target is equal to specified value. + """Check whether the Claim's target is equal to specified value. The function checks for: @@ -2137,8 +2080,7 @@ def target_equals(self, value) -> bool: return self.target == value def has_qualifier(self, qualifier_id: str, target) -> bool: - """ - Check whether Claim contains specified qualifier. + """Check whether Claim contains specified qualifier. :param qualifier_id: id of the qualifier :param target: qualifier target to check presence of @@ -2153,7 +2095,7 @@ def _formatValue(self) -> dict: :return: JSON value """ - # todo: eventually unify the following two groups + # TODO: eventually unify the following two groups if self.type in ('wikibase-item', 'wikibase-property'): value = {'entity-type': self.getTarget().entity_type, 'numeric-id': self.getTarget().getID(numeric=True)} @@ -2177,8 +2119,7 @@ def _formatValue(self) -> dict: return value def _formatDataValue(self) -> dict: - """ - Format the target into the proper JSON datavalue that Wikibase wants. + """Format the target into the proper JSON datavalue for Wikibase. :return: Wikibase API representation with type and value. """ @@ -2224,8 +2165,7 @@ class LexemePage(WikibasePage): } def __init__(self, site, title=None) -> None: - """ - Initializer. + """Initializer. :param site: data repository :type site: pywikibot.site.DataSite @@ -2251,8 +2191,7 @@ def get_data_for_new_entity(self): raise NotImplementedError # TODO def toJSON(self, diffto: dict | None = None) -> dict: - """ - Create JSON suitable for Wikibase API. + """Create JSON suitable for Wikibase API. When diffto is provided, JSON representing differences to the provided data is created. @@ -2271,8 +2210,7 @@ def toJSON(self, diffto: dict | None = None) -> dict: return data def get(self, force=False, get_redirect=False, *args, **kwargs): - """ - Fetch all lexeme data, and cache it. + """Fetch all lexeme data, and cache it. :param force: override caching :type force: bool @@ -2308,8 +2246,7 @@ def get(self, force=False, get_redirect=False, *args, **kwargs): @classmethod def _normalizeData(cls, data: dict) -> dict: - """ - Helper function to expand data into the Wikibase API structure. + """Helper function to expand data into the Wikibase API structure. :param data: The dict to normalize :return: the altered dict from parameter data. @@ -2326,8 +2263,7 @@ def _normalizeData(cls, data: dict) -> dict: @allow_asynchronous def add_form(self, form, **kwargs): - """ - Add a form to the lexeme. + """Add a form to the lexeme. :param form: The form to add :type form: Form @@ -2356,8 +2292,7 @@ def add_form(self, form, **kwargs): self.latest_revision_id = data['lastrevid'] def remove_form(self, form, **kwargs) -> None: - """ - Remove a form from the lexeme. + """Remove a form from the lexeme. :param form: The form to remove :type form: pywikibot.LexemeForm @@ -2368,11 +2303,10 @@ def remove_form(self, form, **kwargs) -> None: form.on_lexeme = None form.id = '-1' - # todo: senses + # TODO: senses def mergeInto(self, lexeme, **kwargs): - """ - Merge the lexeme into another lexeme. + """Merge the lexeme into another lexeme. :param lexeme: The lexeme to merge into :type lexeme: LexemePage @@ -2437,8 +2371,7 @@ def on_lexeme(self): @allow_asynchronous def addClaim(self, claim, **kwargs): - """ - Add a claim to the form. + """Add a claim to the form. :param claim: The claim to add :type claim: Claim @@ -2458,8 +2391,7 @@ def addClaim(self, claim, **kwargs): claim.on_item = self def removeClaims(self, claims, **kwargs) -> None: - """ - Remove the claims from the form. + """Remove the claims from the form. :param claims: list of claims to be removed :type claims: list or pywikibot.Claim @@ -2511,8 +2443,7 @@ def _normalizeData(cls, data): return new_data def get(self, force: bool = False) -> dict: - """ - Fetch all form data, and cache it. + """Fetch all form data, and cache it. :param force: override caching @@ -2532,8 +2463,7 @@ def get(self, force: bool = False) -> dict: return data def edit_elements(self, data: dict, **kwargs) -> None: - """ - Update form elements. + """Update form elements. :param data: Data to be saved """ diff --git a/pywikibot/pagegenerators/__init__.py b/pywikibot/pagegenerators/__init__.py index 64c8089644..b6a0abf215 100644 --- a/pywikibot/pagegenerators/__init__.py +++ b/pywikibot/pagegenerators/__init__.py @@ -567,8 +567,7 @@ def PageClassGenerator(generator: Iterable[pywikibot.page.Page] ) -> Generator[pywikibot.page.Page, None, None]: - """ - Yield pages from another generator as Page subclass objects. + """Yield pages from another generator as Page subclass objects. The page class type depends on the page namespace. Objects may be Category, FilePage, Userpage or Page. @@ -714,8 +713,7 @@ def PreloadingEntityGenerator( generator: Iterable[pywikibot.page.WikibaseEntity], groupsize: int = 50, ) -> Generator[pywikibot.page.WikibaseEntity, None, None]: - """ - Yield preloaded pages taken from another generator. + """Yield preloaded pages taken from another generator. Function basically is copied from above, but for Wikibase entities. diff --git a/pywikibot/pagegenerators/_factory.py b/pywikibot/pagegenerators/_factory.py index 0e2c135db9..b61104717c 100644 --- a/pywikibot/pagegenerators/_factory.py +++ b/pywikibot/pagegenerators/_factory.py @@ -91,8 +91,7 @@ def __init__(self, site: BaseSite | None = None, positional_arg_name: str | None = None, enabled_options: Iterable[str] | None = None, disabled_options: Iterable[str] | None = None) -> None: - """ - Initializer. + """Initializer. :param site: Site for generator results :param positional_arg_name: generator to use for positional args, @@ -160,8 +159,7 @@ def _validate_options(self, @property def site(self) -> pywikibot.site.BaseSite: - """ - Generator site. + """Generator site. The generator site should not be accessed until after the global arguments have been handled, otherwise the default Site may be changed @@ -177,8 +175,7 @@ def site(self) -> pywikibot.site.BaseSite: @property def namespaces(self) -> frozenset[pywikibot.site.Namespace]: - """ - List of Namespace parameters. + """List of Namespace parameters. Converts int or string namespaces to Namespace objects and change the storage to immutable once it has been accessed. @@ -317,8 +314,7 @@ def getCombinedGenerator(self, # noqa: N802 def getCategory(self, category: str # noqa: N802 ) -> tuple[pywikibot.Category, str | None]: - """ - Return Category and start as defined by category. + """Return Category and start as defined by category. :param category: category name with start parameter """ @@ -347,8 +343,7 @@ def getCategoryGen(self, category: str, # noqa: N802 recurse: int | bool = False, content: bool = False, gen_func: Callable | None = None) -> Any: - """ - Return generator based on Category defined by category and gen_func. + """Return generator based on Category defined by category and gen_func. :param category: category name with start parameter :param recurse: if not False or 0, also iterate articles in diff --git a/pywikibot/pagegenerators/_filters.py b/pywikibot/pagegenerators/_filters.py index 5ee3a71c5f..85e811a733 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-2022 +# (C) Pywikibot team, 2008-2024 # # Distributed under the terms of the MIT license. # @@ -50,8 +50,7 @@ def NamespaceFilterPageGenerator( | Sequence[str | Namespace], site: BaseSite | None = None, ) -> Generator[pywikibot.page.BasePage, None, None]: - """ - A generator yielding pages from another generator in given namespaces. + """A generator yielding pages from another generator in given namespaces. If a site is provided, the namespaces are validated using the namespaces of that site, otherwise the namespaces are validated using the default @@ -87,8 +86,7 @@ def PageTitleFilterPageGenerator( generator: Iterable[pywikibot.page.BasePage], ignore_list: dict[str, dict[str, str]], ) -> Generator[pywikibot.page.BasePage, None, None]: - """ - Yield only those pages are not listed in the ignore list. + """Yield only those pages are not listed in the ignore list. :param ignore_list: family names are mapped to dictionaries in which language codes are mapped to lists of page titles. Each title must @@ -115,8 +113,7 @@ def RedirectFilterPageGenerator( no_redirects: bool = True, show_filtered: bool = False, ) -> Generator[pywikibot.page.BasePage, None, None]: - """ - Yield pages from another generator that are redirects or not. + """Yield pages from another generator that are redirects or not. :param no_redirects: Exclude redirects if True, else only include redirects. @@ -150,8 +147,7 @@ def __filter_match(cls, prop: str, claim: str, qualifiers: dict[str, str]) -> bool: - """ - Return true if the page contains the claim given. + """Return true if the page contains the claim given. :param page: the page to check :return: true if page contains the claim, false otherwise @@ -188,8 +184,7 @@ def filter( qualifiers: dict[str, str] | None = None, negate: bool = False, ) -> Generator[pywikibot.page.WikibasePage, None, None]: - """ - Yield all ItemPages which contain certain claim in a property. + """Yield all ItemPages which contain certain claim in a property. :param prop: property id to check :param claim: value of the property to check. Can be exact value (for @@ -213,8 +208,7 @@ def SubpageFilterGenerator(generator: Iterable[pywikibot.page.BasePage], max_depth: int = 0, show_filtered: bool = False ) -> Generator[pywikibot.page.BasePage, None, None]: - """ - Generator which filters out subpages based on depth. + """Generator which filters out subpages based on depth. It looks at the namespace of each page and checks if that namespace has subpages enabled. If so, pages with forward slashes ('/') are excluded. @@ -228,10 +222,9 @@ def SubpageFilterGenerator(generator: Iterable[pywikibot.page.BasePage], for page in generator: if page.depth <= max_depth: yield page - else: - if show_filtered: - pywikibot.info( - f'Page {page} is a subpage that is too deep. Skipping.') + elif show_filtered: + pywikibot.info( + f'Page {page} is a subpage that is too deep. Skipping.') class RegexFilter: @@ -298,7 +291,7 @@ def titlefilter(cls, quantifier = 'any' elif quantifier is True: quantifier = 'none' - reg = cls.__precompile(regex, re.I) + reg = cls.__precompile(regex, re.IGNORECASE) for page in generator: title = page.title(with_ns=not ignore_namespace) if cls.__filter_match(reg, title, quantifier): @@ -325,8 +318,7 @@ def QualityFilterPageGenerator( generator: Iterable[pywikibot.page.BasePage], quality: list[int], ) -> Generator[pywikibot.page.BasePage, None, None]: - """ - Wrap a generator to filter pages according to quality levels. + """Wrap a generator to filter pages according to quality levels. This is possible only for pages with content_model 'proofread-page'. In all the other cases, no filter is applied. @@ -347,8 +339,7 @@ def CategoryFilterPageGenerator( generator: Iterable[pywikibot.page.BasePage], category_list: Sequence[pywikibot.page.Category], ) -> Generator[pywikibot.page.BasePage, None, None]: - """ - Wrap a generator to filter pages by categories specified. + """Wrap a generator to filter pages by categories specified. :param generator: A generator object :param category_list: categories used to filter generated pages @@ -377,8 +368,7 @@ def EdittimeFilterPageGenerator( first_edit_end: datetime.datetime | None = None, show_filtered: bool = False, ) -> Generator[pywikibot.page.BasePage, None, None]: - """ - Wrap a generator to filter pages outside last or first edit range. + """Wrap a generator to filter pages outside last or first edit range. :param generator: A generator object :param last_edit_start: Only yield pages last edited after this time @@ -396,11 +386,8 @@ def to_be_yielded(edit: _Edit, edit_time = rev.timestamp # type: ignore[attr-defined] - msg = '{prefix} edit on {page} was on {time}.\n' \ - 'Too {{when}}. Skipping.' \ - .format(prefix=type(edit).__name__, - page=page, - time=edit_time.isoformat()) + msg = (f'{type(edit).__name__} edit on {page} was on ' + f'{edit_time.isoformat()}.\nToo {{when}}. Skipping.') if edit_time < edit.edit_start: _output_if(show_filtered, msg.format(when='old')) @@ -438,8 +425,7 @@ def UserEditFilterGenerator( max_revision_depth: int | None = None, show_filtered: bool = False ) -> Generator[pywikibot.page.BasePage, None, None]: - """ - Generator which will yield Pages modified by username. + """Generator which will yield Pages modified by username. It only looks at the last editors given by max_revision_depth. If timestamp is set in MediaWiki format JJJJMMDDhhmmss, older edits are @@ -473,8 +459,7 @@ def WikibaseItemFilterPageGenerator( has_item: bool = True, show_filtered: bool = False, ) -> Generator[pywikibot.page.BasePage, None, None]: - """ - A wrapper generator used to exclude if page has a Wikibase item or not. + """A wrapper generator used to exclude if page has a Wikibase item or not. :param generator: Generator to wrap. :param has_item: Exclude pages without an item if True, or only diff --git a/pywikibot/pagegenerators/_generators.py b/pywikibot/pagegenerators/_generators.py index 2956b358c7..ffe6752ecb 100644 --- a/pywikibot/pagegenerators/_generators.py +++ b/pywikibot/pagegenerators/_generators.py @@ -84,8 +84,7 @@ def PrefixingPageGenerator(prefix: str, total: int | None = None, content: bool = False ) -> Iterable[pywikibot.page.Page]: - """ - Prefixed Page generator. + """Prefixed Page generator. :param prefix: The prefix of the pages. :param namespace: Namespace to retrieve pages from @@ -125,8 +124,7 @@ def LogeventsPageGenerator(logtype: str | None = None, end: Timestamp | None = None, reverse: bool = False ) -> Generator[pywikibot.page.Page, None, None]: - """ - Generate Pages for specified modes of logevents. + """Generate Pages for specified modes of logevents. :param logtype: Mode of logs to retrieve :param user: User of logs retrieved @@ -145,9 +143,8 @@ def LogeventsPageGenerator(logtype: str | None = None, try: yield entry.page() except KeyError as e: - pywikibot.warning('LogeventsPageGenerator: ' - 'failed to load page for {!r}; skipping' - .format(entry.data)) + pywikibot.warning('LogeventsPageGenerator: failed to load page ' + f'for {entry.data!r}; skipping') pywikibot.error(e) @@ -158,7 +155,7 @@ def NewpagesPageGenerator(site: BaseSite | None = None, """Iterate Page objects for all new titles in a single namespace. :param site: Site for generator results. - :param namespace: namespace to retrieve pages from + :param namespaces: namespace to retrieve pages from :param total: Maxmium number of pages to retrieve in total """ # API does not (yet) have a newpages function, so this tries to duplicate @@ -176,8 +173,7 @@ def RecentChangesPageGenerator( Iterable[pywikibot.Page]]) = None, **kwargs: Any ) -> Generator[pywikibot.Page, None, None]: - """ - Generate pages that are in the recent changes list, including duplicates. + """Generate recent changes pages, including duplicates. For keyword parameters refer :meth:`APISite.recentchanges() <pywikibot.site._generators.GeneratorsMixin.recentchanges>`. @@ -414,8 +410,7 @@ def TextIOPageGenerator(source: str | None = None, def PagesFromTitlesGenerator(iterable: Iterable[str], site: BaseSite | None = None ) -> Generator[pywikibot.page.Page, None, None]: - """ - Generate pages from the titles (strings) yielded by iterable. + """Generate pages from the titles (strings) yielded by iterable. :param site: Site for generator results. """ @@ -480,8 +475,7 @@ def UserContributionsGenerator(username: str, def NewimagesPageGenerator(total: int | None = None, site: BaseSite | None = None ) -> Generator[pywikibot.page.Page, None, None]: - """ - New file generator. + """New file generator. :param total: Maximum number of pages to retrieve in total :param site: Site for generator results. @@ -494,8 +488,7 @@ def NewimagesPageGenerator(total: int | None = None, def WikibaseItemGenerator(gen: Iterable[pywikibot.page.Page] ) -> Generator[pywikibot.page.ItemPage, None, None]: - """ - A wrapper generator used to yield Wikibase items of another generator. + """A wrapper generator used to yield Wikibase items of another generator. :param gen: Generator to wrap. :return: Wrapped generator @@ -516,8 +509,7 @@ def AncientPagesPageGenerator( total: int = 100, site: BaseSite | None = None ) -> Generator[pywikibot.page.Page, None, None]: - """ - Ancient page generator. + """Ancient page generator. :param total: Maximum number of pages to retrieve in total :param site: Site for generator results. @@ -686,8 +678,7 @@ def DeadendPagesPageGenerator( def LongPagesPageGenerator(total: int = 100, site: BaseSite | None = None ) -> Generator[pywikibot.page.Page, None, None]: - """ - Long page generator. + """Long page generator. :param total: Maximum number of pages to retrieve in total :param site: Site for generator results. @@ -700,8 +691,7 @@ def LongPagesPageGenerator(total: int = 100, def ShortPagesPageGenerator(total: int = 100, site: BaseSite | None = None ) -> Generator[pywikibot.page.Page, None, None]: - """ - Short page generator. + """Short page generator. :param total: Maximum number of pages to retrieve in total :param site: Site for generator results. @@ -785,8 +775,7 @@ def SearchPageGenerator( def LiveRCPageGenerator(site: BaseSite | None = None, total: int | None = None ) -> Generator[pywikibot.page.Page, None, None]: - """ - Yield pages from a socket.io RC stream. + """Yield pages from a socket.io RC stream. Generates pages based on the EventStreams Server-Sent-Event (SSE) recent changes stream. @@ -816,6 +805,7 @@ def LiveRCPageGenerator(site: BaseSite | None = None, class GoogleSearchPageGenerator(GeneratorWrapper): + """Page generator using Google search results. To use this generator, you need to install the package 'google': @@ -834,8 +824,7 @@ class GoogleSearchPageGenerator(GeneratorWrapper): def __init__(self, query: str | None = None, site: BaseSite | None = None) -> None: - """ - Initializer. + """Initializer. :param site: Site for generator results. """ @@ -896,8 +885,7 @@ def generator(self) -> Generator[pywikibot.page.Page, None, None]: def MySQLPageGenerator(query: str, site: BaseSite | None = None, verbose: bool | None = None ) -> Generator[pywikibot.page.Page, None, None]: - """ - Yield a list of pages based on a MySQL query. + """Yield a list of pages based on a MySQL query. The query should return two columns, page namespace and page title pairs from some table. An example query that yields all ns0 pages might look @@ -1055,6 +1043,7 @@ def SupersetPageGenerator(query: str, class XMLDumpPageGenerator(abc.Iterator): # type: ignore[type-arg] + """Xml iterator that yields Page objects. .. versionadded:: 7.2 @@ -1133,8 +1122,7 @@ def __init__(self, *args, **kwargs): def YearPageGenerator(start: int = 1, end: int = 2050, site: BaseSite | None = None ) -> Generator[pywikibot.page.Page, None, None]: - """ - Year page generator. + """Year page generator. :param site: Site for generator results. """ @@ -1153,8 +1141,7 @@ def YearPageGenerator(start: int = 1, end: int = 2050, def DayPageGenerator(start_month: int = 1, end_month: int = 12, site: BaseSite | None = None, year: int = 2000 ) -> Generator[pywikibot.page.Page, None, None]: - """ - Day page generator. + """Day page generator. :param site: Site for generator results. :param year: considering leap year. @@ -1238,8 +1225,7 @@ def WikibaseSearchItemPageGenerator( total: int | None = None, site: BaseSite | None = None, ) -> Generator[pywikibot.page.ItemPage, None, None]: - """ - Generate pages that contain the provided text. + """Generate pages that contain the provided text. :param text: Text to look for. :param language: Code of the language to search in. If not specified, @@ -1259,6 +1245,7 @@ def WikibaseSearchItemPageGenerator( class PetScanPageGenerator(GeneratorWrapper): + """Queries PetScan to generate pages. .. seealso:: https://petscan.wmflabs.org/ @@ -1275,8 +1262,7 @@ def __init__( site: BaseSite | None = None, extra_options: dict[Any, Any] | None = None ) -> None: - """ - Initializer. + """Initializer. :param categories: List of category names to retrieve pages from :param subset_combination: Combination mode. @@ -1298,8 +1284,7 @@ def __init__( def buildQuery(self, categories: Sequence[str], subset_combination: bool, namespaces: Iterable[int | pywikibot.site.Namespace] | None, extra_options: dict[Any, Any] | None) -> dict[str, Any]: - """ - Get the querystring options to query PetScan. + """Get the querystring options to query PetScan. :param categories: List of categories (as strings) :param subset_combination: Combination mode. @@ -1370,6 +1355,7 @@ def generator(self) -> Generator[pywikibot.page.Page, None, None]: class PagePilePageGenerator(GeneratorWrapper): + """Queries PagePile to generate pages. .. seealso:: https://pagepile.toolforge.org/ diff --git a/pywikibot/proofreadpage.py b/pywikibot/proofreadpage.py index 727527dff3..a4526036bc 100644 --- a/pywikibot/proofreadpage.py +++ b/pywikibot/proofreadpage.py @@ -58,6 +58,7 @@ def _bs4_soup(*args: Any, **kwargs: Any) -> None: class TagAttr: + """Tag attribute of <pages />. Represent a single attribute. It is used internally in @@ -164,6 +165,7 @@ def __repr__(self): class TagAttrDesc: + """A descriptor tag. .. versionadded:: 8.0 @@ -192,6 +194,7 @@ def __delete__(self, obj): class PagesTagParser(collections.abc.Container): + """Parser for tag ``<pages />``. .. seealso:: @@ -439,13 +442,13 @@ def __init__(self, source: PageSourceType, title: str = '') -> None: site = source super().__init__(source, title) if self.namespace() != site.proofread_page_ns: - raise ValueError('Page {} must belong to {} namespace' - .format(self.title(), site.proofread_page_ns)) + raise ValueError(f'Page {self.title()} must belong to ' + f'{site.proofread_page_ns} namespace') # Ensure that constants are in line with Extension values. level_list = list(self.site.proofread_levels) if level_list != self.PROOFREAD_LEVELS: - raise ValueError('QLs do not match site values: {} != {}' - .format(level_list, self.PROOFREAD_LEVELS)) + raise ValueError(f'QLs do not match site values: {level_list} != ' + f'{self.PROOFREAD_LEVELS}') self._base, self._base_ext, self._num = self._parse_title() self._multi_page = self._base_ext in self._MULTI_PAGE_EXT @@ -584,8 +587,8 @@ def ql(self) -> int: @decompose def ql(self, value: int) -> None: if value not in self.site.proofread_levels: - raise ValueError('Not valid QL value: {} (legal values: {})' - .format(value, list(self.site.proofread_levels))) + raise ValueError(f'Not valid QL value: {value} (legal values: ' + f'{list(self.site.proofread_levels)})') # TODO: add logic to validate ql value change, considering # site.proofread_levels. self._full_header.ql = value @@ -608,8 +611,10 @@ def status(self) -> str | None: try: return self.site.proofread_levels[self.ql] except KeyError: - pywikibot.warning('Not valid status set for {}: quality level = {}' - .format(self.title(as_link=True), self.ql)) + pywikibot.warning( + f'Not valid status set for {self.title(as_link=True)}: ' + f'quality level = {self.ql}' + ) return None def without_text(self) -> None: @@ -1044,8 +1049,8 @@ def __init__(self, source: PageSourceType, title: str = '') -> None: site = source super().__init__(source, title) if self.namespace() != site.proofread_index_ns: - raise ValueError('Page {} must belong to {} namespace' - .format(self.title(), site.proofread_index_ns)) + raise ValueError(f'Page {self.title()} must belong to ' + f'{site.proofread_index_ns} namespace') self._all_page_links = {} @@ -1091,8 +1096,7 @@ def _parse_redlink(href: str) -> str | None: return None def save(self, *args: Any, **kwargs: Any) -> None: # See Page.save(). - """ - Save page after validating the content. + """Save page after validating the content. Trying to save any other content fails silently with a parameterless INDEX_TEMPLATE being saved. @@ -1179,7 +1183,8 @@ def _get_page_mappings(self) -> None: if not self._soup.find_all('a', attrs=attrs): raise ValueError( 'Missing class="qualityN prp-pagequality-N" or ' - 'class="new" in: {}.'.format(self)) + f'class="new" in: {self}.' + ) page_cnt = 0 for a_tag in self._soup.find_all('a', attrs=attrs): @@ -1265,8 +1270,8 @@ def page_gen( end = self.num_pages if not 1 <= start <= end <= self.num_pages: - raise ValueError('start={}, end={} are not in valid range (1, {})' - .format(start, end, self.num_pages)) + raise ValueError(f'start={start}, end={end} are not in valid ' + f'range (1, {self.num_pages})') # All but 'Without Text' if filter_ql is None: diff --git a/pywikibot/scripts/generate_family_file.py b/pywikibot/scripts/generate_family_file.py index 066d4bf4ea..c2b2e00681 100755 --- a/pywikibot/scripts/generate_family_file.py +++ b/pywikibot/scripts/generate_family_file.py @@ -32,7 +32,7 @@ If the url scheme is missing, ``https`` will be used. """ # -# (C) Pywikibot team, 2010-2023 +# (C) Pywikibot team, 2010-2024 # # Distributed under the terms of the MIT license. # @@ -64,8 +64,7 @@ def __init__(self, name: str | None = None, dointerwiki: str | None = None, verify: str | None = None) -> None: - """ - Parameters are optional. If not given the script asks for the values. + """Parameters are optional. If missing the script asks for the values. :param url: an url from where the family settings are loaded :param name: the family name without "_family.py" tail. @@ -119,8 +118,8 @@ def get_params(self) -> bool: # pragma: no cover return False if any(x not in NAME_CHARACTERS for x in self.name): - print('ERROR: Name of family "{}" must be ASCII letters and ' - 'digits [a-zA-Z0-9]'.format(self.name)) + print(f'ERROR: Name of family "{self.name}" must be ASCII letters' + ' and digits [a-zA-Z0-9]') return False return True @@ -156,9 +155,9 @@ def run(self) -> None: self.wikis[w.lang] = w print('\n==================================' - '\nAPI url: {w.api}' - '\nMediaWiki version: {w.version}' - '\n==================================\n'.format(w=w)) + f'\nAPI url: {w.api}' + f'\nMediaWiki version: {w.version}' + '\n==================================\n') self.getlangs(w) self.getapis() diff --git a/pywikibot/scripts/generate_user_files.py b/pywikibot/scripts/generate_user_files.py index 2864f4beb0..022471a6ce 100755 --- a/pywikibot/scripts/generate_user_files.py +++ b/pywikibot/scripts/generate_user_files.py @@ -86,12 +86,12 @@ def change_base_dir(): # config would find that file return new_base - msg = fill("""WARNING: Your user files will be created in the directory + msg = fill(f"""WARNING: Your user files will be created in the directory '{new_base}' you have chosen. To access these files, you will either have to use the argument "-dir:{new_base}" every time you run the bot, or set the environment variable "PYWIKIBOT_DIR" equal to this directory name in your operating system. See your operating system documentation for how to -set environment variables.""".format(new_base=new_base), width=76) +set environment variables.""", width=76) pywikibot.info(msg) if pywikibot.input_yn('Is this OK?', default=False, automatic_quit=False): return new_base @@ -114,8 +114,7 @@ def get_site_and_lang( default_username: str | None = None, force: bool = False ) -> tuple[str, str, str]: - """ - Ask the user for the family, site code and username. + """Ask the user for the family, site code and username. :param default_family: The default family which should be chosen. :param default_lang: The default site code which should be chosen, @@ -167,8 +166,8 @@ def get_site_and_lang( mycode = pywikibot.input(message, default=default_lang, force=force) if known_langs and mycode and mycode not in known_langs \ and not pywikibot.input_yn( - fill('The site code {!r} is not in the list of known sites. ' - 'Do you want to continue?'.format(mycode)), + fill(f'The site code {mycode!r} is not in the list of known' + ' sites. Do you want to continue?'), default=False, automatic_quit=False): mycode = None @@ -353,8 +352,7 @@ def create_user_config( main_username: str, force: bool = False ): - """ - Create a user-config.py in base_dir. + """Create a user-config.py in base_dir. Create a user-password.py if necessary. """ @@ -384,16 +382,16 @@ def create_user_config( botpasswords = [] userset = {user.name for user in userlist} for username in userset: - if pywikibot.input_yn('Do you want to add a BotPassword for {}?' - .format(username), force=force, default=False): + if pywikibot.input_yn('Do you want to add a BotPassword for ' + f'{username}?', force=force, default=False): if msg: pywikibot.info(msg) msg = None message = f'BotPassword\'s "bot name" for {username}' botpasswordname = pywikibot.input(message, force=force) - message = 'BotPassword\'s "password" for "{}" ' \ + message = f'BotPassword\'s "password" for "{botpasswordname}" ' \ '(no characters will be shown)' \ - .format(botpasswordname) + botpasswordpass = pywikibot.input(message, force=force, password=True) if botpasswordname and botpasswordpass: @@ -405,8 +403,9 @@ def create_user_config( f"# usernames['{main_family}']['{main_code}'] = 'MyUsername'") else: usernames = '\n'.join( - "usernames['{user.family}']['{user.code}'] = '{user.name}'" - .format(user=user) for user in userlist) + f"usernames['{user.family}']['{user.code}'] = '{user.name}'" + for user in userlist + ) # Arbitrarily use the first key as default settings main_family, main_code = userlist[0].family, userlist[0].code botpasswords = '\n'.join( @@ -497,8 +496,7 @@ def ask_for_dir_change(force) -> tuple[bool, bool]: def main(*args: str) -> None: - """ - Process command line arguments and generate user-config. + """Process command line arguments and generate user-config. If args is an empty list, sys.argv is used. diff --git a/pywikibot/scripts/i18n/pywikibot/en.json b/pywikibot/scripts/i18n/pywikibot/en.json index dc41df353a..1421f9bad8 100644 --- a/pywikibot/scripts/i18n/pywikibot/en.json +++ b/pywikibot/scripts/i18n/pywikibot/en.json @@ -1,26 +1,26 @@ { - "@metadata": { - "authors": [ - "Daniel Herding", - "Huji", - "Siebrand", - "Revi", - "Russell Blau", - "Xqt" - ] - }, - "pywikibot-bot-prefix": "Bot:", - "pywikibot-cosmetic-changes": "; cosmetic changes", - "pywikibot-enter-category-name": "Please enter the category name:", - "pywikibot-enter-file-links-processing": "Links to which file page should be processed?", - "pywikibot-enter-finished-browser": "Press Enter when finished in browser.", - "pywikibot-enter-namespace-number": "Please enter a namespace by its number:", - "pywikibot-enter-new-text": "Please enter the new text:", - "pywikibot-enter-page-processing": "Which page should be processed?", - "pywikibot-enter-xml-filename": "Please enter the XML dump's filename:", - "pywikibot-fixes-fckeditor": "Bot: Fixing rich-editor html", - "pywikibot-fixes-html": "Bot: Converting/fixing HTML", - "pywikibot-fixes-isbn": "Bot: Formatting ISBN", - "pywikibot-fixes-syntax": "Bot: Fixing wiki syntax", - "pywikibot-touch": "Pywikibot touch edit" + "@metadata": { + "authors": [ + "Daniel Herding", + "Huji", + "Siebrand", + "Revi", + "Russell Blau", + "Xqt" + ] + }, + "pywikibot-bot-prefix": "Bot:", + "pywikibot-cosmetic-changes": "; cosmetic changes", + "pywikibot-enter-category-name": "Please enter the category name:", + "pywikibot-enter-file-links-processing": "Links to which file page should be processed?", + "pywikibot-enter-finished-browser": "Press Enter when finished in browser.", + "pywikibot-enter-namespace-number": "Please enter a namespace by its number:", + "pywikibot-enter-new-text": "Please enter the new text:", + "pywikibot-enter-page-processing": "Which page should be processed?", + "pywikibot-enter-xml-filename": "Please enter the XML dump's filename:", + "pywikibot-fixes-fckeditor": "Bot: Fixing rich-editor html", + "pywikibot-fixes-html": "Bot: Converting/fixing HTML", + "pywikibot-fixes-isbn": "Bot: Formatting ISBN", + "pywikibot-fixes-syntax": "Bot: Fixing wiki syntax", + "pywikibot-touch": "Pywikibot touch edit" } diff --git a/pywikibot/scripts/login.py b/pywikibot/scripts/login.py index ce3f29f5a3..e47f01f1ab 100755 --- a/pywikibot/scripts/login.py +++ b/pywikibot/scripts/login.py @@ -44,7 +44,7 @@ moved to :mod:`pywikibot.scripts` folder """ # -# (C) Pywikibot team, 2003-2023 +# (C) Pywikibot team, 2003-2024 # # Distributed under the terms of the MIT license. # @@ -83,11 +83,11 @@ def _oauth_login(site) -> None: else: oauth_token = login_manager.consumer_token + login_manager.access_token pywikibot.info( - 'Logged in on {site} as {username} via OAuth consumer {consumer}\n' - 'NOTE: To use OAuth, you need to copy the following line to your ' - 'user config file:\n authenticate[{hostname!r}] = {oauth_token}' - .format(site=site, username=site.username(), consumer=consumer_key, - hostname=site.hostname(), oauth_token=oauth_token)) + f'Logged in on {site} as {site.username()} via OAuth consumer ' + f'{consumer_key}\nNOTE: To use OAuth, you need to copy the' + ' following line to your user config file:\n' + f'authenticate[{site.hostname()!r}] = {oauth_token}' + ) def login_one_site(code, family, oauth, logout, autocreate): @@ -95,9 +95,8 @@ def login_one_site(code, family, oauth, logout, autocreate): try: site = pywikibot.Site(code, family) except SiteDefinitionError: - pywikibot.error('{}:{} is not a valid site, ' - 'please remove it from your user-config' - .format(family, code)) + pywikibot.error(f'{family}:{code} is not a valid site, ' + 'please remove it from your user-config') return if oauth: @@ -122,8 +121,7 @@ def login_one_site(code, family, oauth, logout, autocreate): def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. @@ -173,5 +171,5 @@ def main(*args: str) -> None: start = datetime.datetime.now() with suppress(KeyboardInterrupt): main() - pywikibot.info('\nExecution time: {} seconds' - .format((datetime.datetime.now() - start).seconds)) + pywikibot.info('\nExecution time: ' + f'{(datetime.datetime.now() - start).seconds} seconds') diff --git a/pywikibot/scripts/wrapper.py b/pywikibot/scripts/wrapper.py index fb64afd088..3a16b28396 100755 --- a/pywikibot/scripts/wrapper.py +++ b/pywikibot/scripts/wrapper.py @@ -77,25 +77,26 @@ def check_pwb_versions(package: str): wikibot_version = Version(pwb.__version__) if scripts_version.release > wikibot_version.release: # pragma: no cover - print('WARNING: Pywikibot version {} is behind scripts package ' - 'version {}.\nYour Pywikibot may need an update or be ' - 'misconfigured.\n'.format(wikibot_version, scripts_version)) + 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 - prev_wikibot = Version('{v.major}.{}.{v.micro}' - .format(wikibot_version.minor - 1, - v=wikibot_version)) + prev_wikibot = Version( + f'{wikibot_version.major}.{wikibot_version.minor - 1}.' + f'{wikibot_version.micro}' + ) if scripts_version.release < prev_wikibot.release: - print('WARNING: Scripts package version {} is behind legacy ' - 'Pywikibot version {} and current version {}\nYour scripts ' - 'may need an update or be misconfigured.\n' - .format(scripts_version, prev_wikibot, wikibot_version)) + print(f'WARNING: Scripts package version {scripts_version} is ' + 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 - print('WARNING: Scripts package version {} is behind current version ' - '{}\nYour scripts may need an update or be misconfigured.\n' - .format(scripts_version, wikibot_version)) + 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') del Version @@ -359,9 +360,8 @@ def find_alternates(filename, script_paths): script = similar_scripts[0] wait_time = config.pwb_autostart_waittime info('NOTE: Starting the most similar script ' - '<<lightyellow>>{}.py<<default>>\n' - ' in {} seconds; type CTRL-C to stop.' - .format(script, wait_time)) + f'<<lightyellow>>{script}.py<<default>>\n' + f' in {wait_time} seconds; type CTRL-C to stop.') try: sleep(wait_time) # Wait a bit to let it be cancelled except KeyboardInterrupt: diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py index 2a3ec9cf1d..3802d23b29 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -155,8 +155,7 @@ def __setstate__(self, state: dict[str, Any]) -> None: self._tokens = TokenWallet(self) def interwiki(self, prefix: str) -> BaseSite: - """ - Return the site for a corresponding interwiki prefix. + """Return the site for a corresponding interwiki prefix. :raises pywikibot.exceptions.SiteDefinitionError: if the url given in the interwiki table doesn't match any of the existing families. @@ -165,8 +164,7 @@ def interwiki(self, prefix: str) -> BaseSite: return self._interwikimap[prefix].site def interwiki_prefix(self, site: BaseSite) -> list[str]: - """ - Return the interwiki prefixes going to that site. + """Return the interwiki prefixes going to that site. The interwiki prefixes are ordered first by length (shortest first) and then alphabetically. :py:obj:`interwiki(prefix)` is not @@ -186,8 +184,7 @@ def interwiki_prefix(self, site: BaseSite) -> list[str]: return sorted(prefixes, key=lambda p: (len(p), p)) def local_interwiki(self, prefix: str) -> bool: - """ - Return whether the interwiki prefix is local. + """Return whether the interwiki prefix is local. A local interwiki prefix is handled by the target site like a normal link. So if that link also contains an interwiki link it does follow @@ -289,8 +286,7 @@ def _generator( @staticmethod def _request_class(kwargs: dict[str, Any]) -> type[api.Request]: - """ - Get the appropriate class. + """Get the appropriate class. Inside this class kwargs use the parameters mode but QueryGenerator may use the old kwargs mode. @@ -459,8 +455,7 @@ def _relogin(self) -> None: self.login() def logout(self) -> None: - """ - Logout of the site and load details for the logged out user. + """Logout of the site and load details for the logged out user. Also logs out of the global account if linked to the user. @@ -722,8 +717,8 @@ def get_globaluserinfo(self, elif isinstance(user, int): param = {'guiid': user} else: - raise TypeError("Inappropriate argument type of 'user' ({})" - .format(type(user).__name__)) + raise TypeError("Inappropriate argument type of 'user' " + f'({type(user).__name__})') if force or user not in self._globaluserinfo: param.update( @@ -797,8 +792,7 @@ def is_locked(self, return 'locked' in self.get_globaluserinfo(user, force) def get_searched_namespaces(self, force: bool = False) -> set[Namespace]: - """ - Retrieve the default searched namespaces for the user. + """Retrieve the default searched namespaces for the user. If no user is logged in, it returns the namespaces used by default. Otherwise it returns the user preferences. It caches the last result @@ -872,6 +866,9 @@ def linktrail(self) -> str: if linktrail == '/^()(.*)$/sD': # empty linktrail return '' + # T378787 + linktrail = linktrail.replace(r'\p{L}', r'[^\W\d_]') + match = re.search(r'\((?:\:\?|\?\:)?\[(?P<pattern>.+?)\]' r'(?P<letters>(\|.)*)\)?\+\)', linktrail) if not match: @@ -1125,8 +1122,7 @@ def expand_text( return req.submit()['expandtemplates']['wikitext'] def getcurrenttimestamp(self) -> str: - """ - Return the server time as a MediaWiki timestamp string. + """Return the server time as a MediaWiki timestamp string. It calls :py:obj:`server_time` first so it queries the server to get the current server time. @@ -1136,8 +1132,7 @@ def getcurrenttimestamp(self) -> str: return self.server_time().totimestampformat() def server_time(self) -> pywikibot.Timestamp: - """ - Return a Timestamp object representing the current server time. + """Return a Timestamp object representing the current server time. It uses the 'time' property of the siteinfo 'general'. It'll force a reload before returning the time. @@ -1251,6 +1246,14 @@ def version(self) -> str: pywikibot.error(msg) raise + if MediaWikiVersion(version) < '1.31': + warn('\n' + + fill(f'Support of MediaWiki {version} will be dropped. ' + 'It is recommended to use MediaWiki 1.31 or above. ' + 'You may use every Pywikibot 9.X for older MediaWiki ' + 'versions. See T378984 for further information.'), + FutureWarning) + if MediaWikiVersion(version) < '1.27': raise RuntimeError(f'Pywikibot "{pywikibot.__version__}" does not ' f'support MediaWiki "{version}".\n' @@ -1293,8 +1296,7 @@ def image_repository(self) -> BaseSite | None: return None def data_repository(self) -> pywikibot.site.DataSite | None: - """ - Return the data repository connected to this site. + """Return the data repository connected to this site. :return: The data repository if one is connected or None otherwise. """ @@ -1337,8 +1339,7 @@ def page_from_repository( self, item: str ) -> pywikibot.page.Page | None: - """ - Return a Page for this site object specified by Wikibase item. + """Return a Page for this site object specified by Wikibase item. Usage: @@ -1571,9 +1572,10 @@ def page_can_be_edited( :raises ValueError: invalid action parameter """ if action not in self.siteinfo.get('restrictions')['types']: - raise ValueError('{}.page_can_be_edited(): Invalid value "{}" for ' - '"action" parameter' - .format(self.__class__.__name__, action)) + raise ValueError( + f'{type(self).__name__}.page_can_be_edited(): ' + f'Invalid value "{action}" for "action" parameter' + ) prot_rights = { '': action, 'autoconfirmed': 'editsemiprotected', @@ -2501,8 +2503,9 @@ def movepage( # TODO: Check for talkmove-error messages if 'talkmove-error-code' in result['move']: pywikibot.warning( - 'movepage: Talk page {} not moved' - .format(page.toggleTalkPage().title(as_link=True))) + 'movepage: Talk page ' + f'{page.toggleTalkPage().title(as_link=True)} not moved' + ) return pywikibot.Page(page, newtitle) # catalog of rollback errors for use in error messages @@ -2626,8 +2629,9 @@ def delete( """ if oldimage and isinstance(page, pywikibot.page.BasePage) \ and not isinstance(page, pywikibot.FilePage): - raise TypeError("'page' must be a FilePage not a '{}'" - .format(page.__class__.__name__)) + raise TypeError( + f"'page' must be a FilePage not a '{page.__class__.__name__}'" + ) token = self.tokens['csrf'] params = { @@ -2747,8 +2751,7 @@ def undelete( } def protection_types(self) -> set[str]: - """ - Return the protection types available on this site. + """Return the protection types available on this site. **Example:** @@ -2764,8 +2767,7 @@ def protection_types(self) -> set[str]: @need_version('1.27.3') def protection_levels(self) -> set[str]: - """ - Return the protection levels available on this site. + """Return the protection levels available on this site. **Example:** @@ -2862,8 +2864,7 @@ def blockuser( reblock: bool = False, allowusertalk: bool = False ) -> dict[str, Any]: - """ - Block a user for certain amount of time and for a certain reason. + """Block a user for certain amount of time and for a certain reason. .. seealso:: :api:`Block` @@ -2908,8 +2909,7 @@ def unblockuser( user: pywikibot.page.User, reason: str | None = None ) -> dict[str, Any]: - """ - Remove the block for the user. + """Remove the block for the user. .. seealso:: :api:`Block` @@ -2957,8 +2957,7 @@ def purgepages( converttitles: bool = False, redirects: bool = False ) -> bool: - """ - Purge the server's cache for one or multiple pages. + """Purge the server's cache for one or multiple pages. :param pages: list of Page objects :param redirects: Automatically resolve redirects. @@ -3054,8 +3053,7 @@ def upload( return Uploader(self, filepage, **kwargs).upload() def get_property_names(self, force: bool = False) -> list[str]: - """ - Get property names for pages_with_property(). + """Get property names for pages_with_property(). .. seealso:: :api:`Pagepropnames` @@ -3072,8 +3070,7 @@ def compare( diff: _CompType, difftype: str = 'table' ) -> str: - """ - Corresponding method to the 'action=compare' API action. + """Corresponding method to the 'action=compare' API action. .. hint:: Use :func:`diff.html_comparator` function to parse result. diff --git a/pywikibot/site/_basesite.py b/pywikibot/site/_basesite.py index 570f23c724..86ee9f83e2 100644 --- a/pywikibot/site/_basesite.py +++ b/pywikibot/site/_basesite.py @@ -37,8 +37,7 @@ class BaseSite(ComparableMixin): """Site methods that are independent of the communication interface.""" def __init__(self, code: str, fam=None, user=None) -> None: - """ - Initializer. + """Initializer. :param code: the site's language code :type code: str @@ -73,9 +72,9 @@ def __init__(self, code: str, fam=None, user=None) -> None: else: # no such language anymore self.obsolete = True - pywikibot.log('Site {} instantiated and marked "obsolete" ' - 'to prevent access'.format(self)) - elif self.__code not in self.languages(): + pywikibot.log(f'Site {self} instantiated and marked "obsolete"' + ' to prevent access') + elif self.__code not in self.codes: if self.__family.name in self.__family.langs \ and len(self.__family.langs) == 1: self.__code = self.__family.name @@ -83,11 +82,11 @@ def __init__(self, code: str, fam=None, user=None) -> None: and code == pywikibot.config.mylang: pywikibot.config.mylang = self.__code warn('Global configuration variable "mylang" changed to ' - '"{}" while instantiating site {}' - .format(self.__code, self), UserWarning) + f'"{self.__code}" while instantiating site {self}', + UserWarning) else: - error_msg = ("Language '{}' does not exist in family {}" - .format(self.__code, self.__family.name)) + error_msg = (f"Language '{self.__code}' does not exist in " + f'family {self.__family.name}') raise UnknownSiteError(error_msg) self._username = normalize_username(user) @@ -122,8 +121,7 @@ def family(self): @property def code(self): - """ - The identifying code for this Site equal to the wiki prefix. + """The identifying code for this Site equal to the wiki prefix. By convention, this is usually an ISO language code, but it does not have to be. @@ -152,15 +150,14 @@ def doc_subpage(self) -> tuple: # should it just raise an Exception and fail? # this will help to check the dictionary ... except KeyError: - warn('Site {} has no language defined in ' - 'doc_subpages dict in {}_family.py file' - .format(self, self.family.name), - FamilyMaintenanceWarning, 2) + warn(f'Site {self} has no language defined in ' + f'doc_subpages dict in {self.family.name}_family.py ' + 'file', FamilyMaintenanceWarning, 2) # doc_subpages not defined in x_family.py file except AttributeError: doc = () # default - warn('Site {} has no doc_subpages dict in {}_family.py file' - .format(self, self.family.name), + warn(f'Site {self} has no doc_subpages dict in ' + f'{self.family.name}_family.py file', FamilyMaintenanceWarning, 2) return doc @@ -234,13 +231,27 @@ def __hash__(self): """Return hash value of instance.""" return hash(repr(self)) - def languages(self): - """Return list of all valid language codes for this site's Family.""" - return list(self.family.langs.keys()) + @deprecated('codes', since='9.6') + def languages(self) -> list[str]: + """Return list of all valid site codes for this site's Family. + + .. deprecated:: 9.6 + Use :meth:`codes` instead. + """ + return sorted(self.codes) + + @property + def codes(self) -> set[str]: + """Return set of all valid site codes for this site's Family. + + .. versionadded:: 9.6 + .. seealso:: :attr:`family.Family.codes` + """ + return set(self.family.langs.keys()) def validLanguageLinks(self): # noqa: N802 """Return list of language codes to be used in interwiki links.""" - return [lang for lang in self.languages() + return [lang for lang in sorted(self.codes) if self.namespaces.lookup_normalized_name(lang) is None] def _interwiki_urls(self, only_article_suffixes: bool = False): @@ -296,8 +307,7 @@ def pagename2codes(self) -> list[str]: return ['PAGENAMEE'] def lock_page(self, page, block: bool = True): - """ - Lock page for writing. Must be called before writing any page. + """Lock page for writing. Must be called before writing any page. We don't want different threads trying to write to the same page at the same time, even to different sections. @@ -317,8 +327,7 @@ def lock_page(self, page, block: bool = True): self._locked_pages.add(title) def unlock_page(self, page) -> None: - """ - Unlock page. Call as soon as a write operation has completed. + """Unlock page. Call as soon as a write operation has completed. :param page: the page to be locked :type page: pywikibot.Page @@ -336,10 +345,9 @@ def disambcategory(self): try: item = self.family.disambcatname[repo.code] except KeyError: - raise Error( - 'No {repo} qualifier found for disambiguation category ' - 'name in {fam}_family file'.format(repo=repo_name, - fam=self.family.name)) + raise Error(f'No {repo_name} qualifier found for' + ' disambiguation category name in ' + f'{self.family.name}_family file') dp = pywikibot.ItemPage(repo, item) try: @@ -385,8 +393,7 @@ def redirect_regex(self) -> Pattern[str]: re.IGNORECASE | re.DOTALL) def sametitle(self, title1: str, title2: str) -> bool: - """ - Return True if title1 and title2 identify the same wiki page. + """Return True if title1 and title2 identify the same wiki page. title1 and title2 may be unequal but still identify the same page, if they use different aliases for the same namespace. diff --git a/pywikibot/site/_datasite.py b/pywikibot/site/_datasite.py index b69562dd65..85684857fa 100644 --- a/pywikibot/site/_datasite.py +++ b/pywikibot/site/_datasite.py @@ -50,8 +50,7 @@ def __init__(self, *args, **kwargs) -> None: } def get_repo_for_entity_type(self, entity_type: str) -> DataSite: - """ - Get the data repository for the entity type. + """Get the data repository for the entity type. When no foreign repository is defined for the entity type, the method returns this repository itself even if it does not @@ -86,8 +85,7 @@ def _cache_entity_namespaces(self) -> None: break def get_namespace_for_entity_type(self, entity_type): - """ - Return namespace for given entity type. + """Return namespace for given entity type. :return: corresponding namespace :rtype: Namespace @@ -97,14 +95,13 @@ def get_namespace_for_entity_type(self, entity_type): if entity_type in self._entity_namespaces: return self._entity_namespaces[entity_type] raise EntityTypeUnknownError( - '{!r} does not support entity type "{}" ' - "or it doesn't have its own namespace" - .format(self, entity_type)) + f'{self!r} does not support entity type "{entity_type}" ' + " or it doesn't have its own namespace" + ) @property def item_namespace(self): - """ - Return namespace for items. + """Return namespace for items. :return: item namespace :rtype: Namespace @@ -115,8 +112,7 @@ def item_namespace(self): @property def property_namespace(self): - """ - Return namespace for properties. + """Return namespace for properties. :return: property namespace :rtype: Namespace @@ -127,8 +123,7 @@ def property_namespace(self): return self._property_namespace def get_entity_for_entity_id(self, entity_id): - """ - Return a new instance for given entity id. + """Return a new instance for given entity id. :raises pywikibot.exceptions.NoWikibaseEntityError: there is no entity with the id @@ -145,8 +140,7 @@ def get_entity_for_entity_id(self, entity_id): @property @need_version('1.28-wmf.3') def sparql_endpoint(self): - """ - Return the sparql endpoint url, if any has been set. + """Return the sparql endpoint url, if any has been set. :return: sparql endpoint url :rtype: str|None @@ -156,8 +150,7 @@ def sparql_endpoint(self): @property @need_version('1.28-wmf.23') def concept_base_uri(self): - """ - Return the base uri for concepts/entities. + """Return the base uri for concepts/entities. :return: concept base uri :rtype: str @@ -182,8 +175,7 @@ def tabular_data_repository(self): return None def loadcontent(self, identification, *props): - """ - Fetch the current content of a Wikibase item. + """Fetch the current content of a Wikibase item. This is called loadcontent since wbgetentities does not support fetching old @@ -229,15 +221,14 @@ def preload_entities( ident = p._defined_by() for key in ident: req[key].append(ident[key]) + elif (p.site == self + and p.namespace() in self._entity_namespaces.values()): + req['ids'].append(p.title(with_ns=False)) else: - if p.site == self and p.namespace() in ( - self._entity_namespaces.values()): - req['ids'].append(p.title(with_ns=False)) - else: - assert p.site.has_data_repository, \ - 'Site must have a data repository' - req['sites'].append(p.site.dbName()) - req['titles'].append(p._link._text) + assert p.site.has_data_repository, \ + 'Site must have a data repository' + req['sites'].append(p.site.dbName()) + req['titles'].append(p._link._text) req = self.simple_request(action='wbgetentities', **req) data = req.submit() @@ -354,9 +345,9 @@ def editEntity(self, params['token'] = self.tokens['csrf'] - for arg in kwargs: + for arg, param in kwargs.items(): if arg in ['clear', 'summary', 'tags']: - params[arg] = kwargs[arg] + params[arg] = param elif arg != 'baserevid': warn(f'Unknown wbeditentity parameter {arg} ignored', UserWarning, 2) @@ -451,8 +442,7 @@ def save_claim(self, summary: str | None = None, bot: bool = True, tags: str | None = None): - """ - Save the whole claim to the wikibase site. + """Save the whole claim to the wikibase site. .. versionchanged:: 9.4 *tags* parameter was added @@ -776,8 +766,7 @@ def mergeItems(self, @need_extension('WikibaseLexeme') def mergeLexemes(self, from_lexeme, to_lexeme, summary=None, *, bot: bool = True) -> dict: - """ - Merge two lexemes together. + """Merge two lexemes together. :param from_lexeme: Lexeme to merge from :type from_lexeme: pywikibot.LexemePage @@ -802,8 +791,7 @@ def mergeLexemes(self, from_lexeme, to_lexeme, summary=None, *, @need_right('item-redirect') def set_redirect_target(self, from_item, to_item, bot: bool = True): - """ - Make a redirect to another item. + """Make a redirect to another item. :param to_item: title of target item. :type to_item: pywikibot.ItemPage @@ -823,8 +811,7 @@ def set_redirect_target(self, from_item, to_item, bot: bool = True): def search_entities(self, search: str, language: str, total: int | None = None, **kwargs): - """ - Search for pages or properties that contain the given text. + """Search for pages or properties that contain the given text. :param search: Text to find. :param language: Language to search in. @@ -858,8 +845,7 @@ def parsevalue(self, datatype: str, values: list[str], options: dict[str, Any] | None = None, language: str | None = None, validate: bool = False) -> list[Any]: - """ - Send data values to the wikibase parser for interpretation. + """Send data values to the wikibase parser for interpretation. .. versionadded:: 7.5 .. seealso:: `wbparsevalue API @@ -906,15 +892,14 @@ def parsevalue(self, datatype: str, values: list[str], if 'value' not in result_hash: # There should be an APIError occurred already raise RuntimeError("Unexpected missing 'value' in query data:" - '\n{}'.format(result_hash)) + f'\n{result_hash}') results.append(result_hash['value']) return results @need_right('edit') def _wbset_action(self, itemdef, action: str, action_data, **kwargs) -> dict: - """ - Execute wbset{action} on a Wikibase entity. + """Execute wbset{action} on a Wikibase entity. Supported actions are: wbsetaliases, wbsetdescription, wbsetlabel and wbsetsitelink @@ -1017,27 +1002,25 @@ def prepare_data(action, data): }) params.update(prepare_data(action, action_data)) - for arg in kwargs: + for arg, param in kwargs.items(): if arg in ['summary', 'tags']: - params[arg] = kwargs[arg] + params[arg] = param else: - warn('Unknown parameter {} for action {}, ignored' - .format(arg, action), UserWarning, 2) + warn(f'Unknown parameter {arg} for action {action}, ignored', + UserWarning, 2) req = self.simple_request(**params) return req.submit() def wbsetaliases(self, itemdef, aliases, **kwargs): - """ - Set aliases for a single Wikibase entity. + """Set aliases for a single Wikibase entity. See self._wbset_action() for parameters """ return self._wbset_action(itemdef, 'wbsetaliases', aliases, **kwargs) def wbsetdescription(self, itemdef, description, **kwargs): - """ - Set description for a single Wikibase entity. + """Set description for a single Wikibase entity. See self._wbset_action() """ @@ -1045,16 +1028,14 @@ def wbsetdescription(self, itemdef, description, **kwargs): **kwargs) def wbsetlabel(self, itemdef, label, **kwargs): - """ - Set label for a single Wikibase entity. + """Set label for a single Wikibase entity. See self._wbset_action() for parameters """ return self._wbset_action(itemdef, 'wbsetlabel', label, **kwargs) def wbsetsitelink(self, itemdef, sitelink, **kwargs): - """ - Set, remove or modify a sitelink on a Wikibase item. + """Set, remove or modify a sitelink on a Wikibase item. See self._wbset_action() for parameters """ @@ -1064,8 +1045,7 @@ def wbsetsitelink(self, itemdef, sitelink, **kwargs): @need_extension('WikibaseLexeme') def add_form(self, lexeme, form, *, bot: bool = True, baserevid=None) -> dict: - """ - Add a form. + """Add a form. :param lexeme: Lexeme to modify :type lexeme: pywikibot.LexemePage @@ -1091,8 +1071,7 @@ def add_form(self, lexeme, form, *, bot: bool = True, @need_right('edit') @need_extension('WikibaseLexeme') def remove_form(self, form, *, bot: bool = True, baserevid=None) -> dict: - """ - Remove a form. + """Remove a form. :param form: Form to be removed :type form: pywikibot.LexemeForm @@ -1116,8 +1095,7 @@ def remove_form(self, form, *, bot: bool = True, baserevid=None) -> dict: @need_extension('WikibaseLexeme') def edit_form_elements(self, form, data, *, bot: bool = True, baserevid=None) -> dict: - """ - Edit lexeme form elements. + """Edit lexeme form elements. :param form: Form :type form: pywikibot.LexemeForm diff --git a/pywikibot/site/_extensions.py b/pywikibot/site/_extensions.py index 00b15a1b11..4b6243aaa7 100644 --- a/pywikibot/site/_extensions.py +++ b/pywikibot/site/_extensions.py @@ -190,8 +190,7 @@ class PageImagesMixin: @need_extension('PageImages') def loadpageimage(self, page) -> None: - """ - Load [[mw:Extension:PageImages]] info. + """Load [[mw:Extension:PageImages]] info. :param page: The page for which to obtain the image :type page: pywikibot.Page @@ -247,9 +246,8 @@ def globalusage(self, page, total=None): try: gu_site = pywikibot.Site(url=entry['url']) except SiteDefinitionError: - pywikibot.warning( - 'Site could not be defined for global' - ' usage for {}: {}.'.format(page, entry)) + pywikibot.warning('Site could not be defined for global ' + f'usage for {page}: {entry}.') continue gu_page = pywikibot.Page(gu_site, entry['title']) yield gu_page @@ -399,8 +397,7 @@ class FlowMixin: @need_extension('Flow') @deprecated(since='9.4.0') def load_board(self, page): - """ - Retrieve the data for a Flow board. + """Retrieve the data for a Flow board. :param page: A Flow board :type page: Board @@ -425,8 +422,7 @@ def load_topiclist(self, offset_id: str | None = None, reverse: bool = False, include_offset: bool = False) -> dict[str, Any]: - """ - Retrieve the topiclist of a Flow board. + """Retrieve the topiclist of a Flow board. .. versionchanged:: 8.0 All parameters except *page* are keyword only parameters. @@ -460,8 +456,7 @@ def load_topiclist(self, @need_extension('Flow') @deprecated(since='9.4.0') def load_topic(self, page, content_format: str): - """ - Retrieve the data for a Flow topic. + """Retrieve the data for a Flow topic. :param page: A Flow topic :type page: Topic @@ -479,8 +474,7 @@ def load_topic(self, page, content_format: str): @need_extension('Flow') @deprecated(since='9.4.0') def load_post_current_revision(self, page, post_id, content_format: str): - """ - Retrieve the data for a post to a Flow topic. + """Retrieve the data for a post to a Flow topic. :param page: A Flow topic :type page: Topic @@ -501,8 +495,7 @@ def load_post_current_revision(self, page, post_id, content_format: str): @need_extension('Flow') @deprecated(since='9.4.0') def create_new_topic(self, page, title, content, content_format): - """ - Create a new topic on a Flow board. + """Create a new topic on a Flow board. :param page: A Flow board :type page: Board @@ -550,8 +543,7 @@ def reply_to_post(self, page, reply_to_uuid: str, content: str, @need_extension('Flow') @deprecated(since='9.4.0') def lock_topic(self, page, lock, reason): - """ - Lock or unlock a Flow topic. + """Lock or unlock a Flow topic. :param page: A Flow topic :type page: Topic @@ -575,8 +567,7 @@ def lock_topic(self, page, lock, reason): @need_extension('Flow') @deprecated(since='9.4.0') def moderate_topic(self, page, state, reason): - """ - Moderate a Flow topic. + """Moderate a Flow topic. :param page: A Flow topic :type page: Topic @@ -598,8 +589,7 @@ def moderate_topic(self, page, state, reason): @need_extension('Flow') @deprecated(since='9.4.0') def summarize_topic(self, page, summary): - """ - Add summary to Flow topic. + """Add summary to Flow topic. :param page: A Flow topic :type page: Topic @@ -623,8 +613,7 @@ def summarize_topic(self, page, summary): @need_extension('Flow') @deprecated(since='9.4.0') def delete_topic(self, page, reason): - """ - Delete a Flow topic. + """Delete a Flow topic. :param page: A Flow topic :type page: Topic @@ -639,8 +628,7 @@ def delete_topic(self, page, reason): @need_extension('Flow') @deprecated(since='9.4.0') def hide_topic(self, page, reason): - """ - Hide a Flow topic. + """Hide a Flow topic. :param page: A Flow topic :type page: Topic @@ -655,8 +643,7 @@ def hide_topic(self, page, reason): @need_extension('Flow') @deprecated(since='9.4.0') def suppress_topic(self, page, reason): - """ - Suppress a Flow topic. + """Suppress a Flow topic. :param page: A Flow topic :type page: Topic @@ -671,8 +658,7 @@ def suppress_topic(self, page, reason): @need_extension('Flow') @deprecated(since='9.4.0') def restore_topic(self, page, reason): - """ - Restore a Flow topic. + """Restore a Flow topic. :param page: A Flow topic :type page: Topic @@ -687,8 +673,7 @@ def restore_topic(self, page, reason): @need_extension('Flow') @deprecated(since='9.4.0') def moderate_post(self, post, state, reason): - """ - Moderate a Flow post. + """Moderate a Flow post. :param post: A Flow post :type post: Post @@ -713,8 +698,7 @@ def moderate_post(self, post, state, reason): @need_extension('Flow') @deprecated(since='9.4.0') def delete_post(self, post, reason): - """ - Delete a Flow post. + """Delete a Flow post. :param post: A Flow post :type post: Post @@ -729,8 +713,7 @@ def delete_post(self, post, reason): @need_extension('Flow') @deprecated(since='9.4.0') def hide_post(self, post, reason): - """ - Hide a Flow post. + """Hide a Flow post. :param post: A Flow post :type post: Post @@ -745,8 +728,7 @@ def hide_post(self, post, reason): @need_extension('Flow') @deprecated(since='9.4.0') def suppress_post(self, post, reason): - """ - Suppress a Flow post. + """Suppress a Flow post. :param post: A Flow post :type post: Post @@ -761,8 +743,7 @@ def suppress_post(self, post, reason): @need_extension('Flow') @deprecated(since='9.4.0') def restore_post(self, post, reason): - """ - Restore a Flow post. + """Restore a Flow post. :param post: A Flow post :type post: Post @@ -780,8 +761,7 @@ class UrlShortenerMixin: @need_extension('UrlShortener') def create_short_link(self, url): - """ - Return a shortened link. + """Return a shortened link. Note that on Wikimedia wikis only metawiki supports this action, and this wiki can process links to all WM domains. diff --git a/pywikibot/site/_generators.py b/pywikibot/site/_generators.py index 8b1221dedc..8f5c7f707a 100644 --- a/pywikibot/site/_generators.py +++ b/pywikibot/site/_generators.py @@ -66,8 +66,7 @@ def load_pages_from_pageids( self, pageids: str | Iterable[int | str], ) -> Generator[pywikibot.Page, None, None]: - """ - Return a page generator from pageids. + """Return a page generator from pageids. Pages are iterated in the same order than in the underlying pageids. @@ -210,9 +209,9 @@ def preloadpages( # This checks to see if there is a normalized title in # the response that corresponds to the canonical form # used in the query. - for key in cache: + for key, value in cache.items(): if self.sametitle(key, pagedata['title']): - cache[pagedata['title']] = cache[key] + cache[pagedata['title']] = value break else: pywikibot.warning( @@ -387,8 +386,7 @@ def pagereferences( total: int | None = None, content: bool = False, ) -> Iterable[pywikibot.Page]: - """ - Convenience method combining pagebacklinks and page_embeddedin. + """Convenience method combining pagebacklinks and page_embeddedin. :param namespaces: If present, only return links from the namespaces in this list. @@ -453,21 +451,32 @@ def pagelinks( g_content=content, redirects=follow_redirects, **plargs) - # Sortkey doesn't work with generator def pagecategories( self, page: pywikibot.Page, *, + with_sort_key: bool = False, total: int | None = None, content: bool = False, ) -> Iterable[pywikibot.Page]: """Iterate categories to which page belongs. - .. seealso:: :api:`Categories` + .. versionadded:: 9.6 + the *with_sort_key* parameter. - :param content: if True, load the current content of each iterated page - (default False); note that this means the contents of the - category description page, not the pages contained in the category + .. seealso:: + - :meth:`page.BasePage.categories` + - :api:`Categories` + + .. note:: This method also yields categories which are + transcluded. + + :param with_sort_key: if True, include the sort key in each + Category + :param content: if True, load the current content of each + iterated page default False); note that this means the + contents of the category description page, not the pages + contained in the category """ clargs: dict[str, Any] = {} if hasattr(page, '_pageid'): @@ -475,9 +484,27 @@ def pagecategories( else: clargs['titles'] = page.title( with_section=False).encode(self.encoding()) + + if with_sort_key: + page_dict = next(iter(self._generator( + api.PropertyGenerator, + type_arg='categories', + total=total, + clprop='sortkey', + **clargs))) + cats = ( + pywikibot.Category(self, + cat_dict['title'], + sort_key=cat_dict['sortkeyprefix'] or None) + for cat_dict in page_dict.get('categories', []) + ) + return self.preloadpages(cats) if content else cats + return self._generator(api.PageGenerator, - type_arg='categories', total=total, - g_content=content, **clargs) + type_arg='categories', + total=total, + g_content=content, + **clargs) def pageimages( self, @@ -1791,8 +1818,7 @@ def alldeletedrevisions( total: int | None = None, **kwargs, ) -> Generator[dict[str, Any], None, None]: - """ - Yield all deleted revisions. + """Yield all deleted revisions. .. seealso:: :api:`Alldeletedrevisions` .. warning:: *user* keyword argument must be given together with @@ -1987,8 +2013,9 @@ def patrol( if err.code in self._patrol_errors: raise Error(self._patrol_errors[err.code] .format_map(errdata)) - pywikibot.debug("protect: Unexpected error code '{}' received." - .format(err.code)) + pywikibot.debug( + f"protect: Unexpected error code '{err.code}' received." + ) raise yield result['patrol'] diff --git a/pywikibot/site/_interwikimap.py b/pywikibot/site/_interwikimap.py index 8711194846..f62accf113 100644 --- a/pywikibot/site/_interwikimap.py +++ b/pywikibot/site/_interwikimap.py @@ -1,6 +1,6 @@ """Objects representing interwiki map of MediaWiki site.""" # -# (C) Pywikibot team, 2015-2022 +# (C) Pywikibot team, 2015-2024 # # Distributed under the terms of the MIT license. # @@ -35,8 +35,7 @@ class _InterwikiMap: """A representation of the interwiki map of a site.""" def __init__(self, site) -> None: - """ - Create an empty uninitialized interwiki map for the given site. + """Create an empty uninitialized interwiki map for the given site. :param site: Given site for which interwiki map is to be created :type site: pywikibot.site.APISite @@ -62,8 +61,7 @@ def _iw_sites(self): return self._map def __getitem__(self, prefix): - """ - Return the site, locality and url for the requested prefix. + """Return the site, locality and url for the requested prefix. :param prefix: Interwiki prefix :type prefix: Dictionary key @@ -77,12 +75,11 @@ def __getitem__(self, prefix): return self._iw_sites[prefix] if isinstance(self._iw_sites[prefix].site, Exception): raise self._iw_sites[prefix].site - raise TypeError('_iw_sites[{}] is wrong type: {}' - .format(prefix, type(self._iw_sites[prefix].site))) + raise TypeError(f'_iw_sites[{prefix}] is wrong type: ' + f'{type(self._iw_sites[prefix].site)}') def get_by_url(self, url: str) -> set[str]: - """ - Return a set of prefixes applying to the URL. + """Return a set of prefixes applying to the URL. :param url: URL for the interwiki """ diff --git a/pywikibot/site/_namespace.py b/pywikibot/site/_namespace.py index 1d707442ef..dd282cb916 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-2023 +# (C) Pywikibot team, 2008-2024 # # Distributed under the terms of the MIT license. # @@ -71,8 +71,7 @@ def __new__(cls, name, bases, dic): class Namespace(Iterable, ComparableMixin, metaclass=MetaNamespace): - """ - Namespace site data object. + """Namespace site data object. This is backwards compatible with the structure of entries in site._namespaces which were a list of:: @@ -274,14 +273,10 @@ def __repr__(self) -> str: else: kwargs = '' - return '{}(id={}, custom_name={!r}, canonical_name={!r}, ' \ - 'aliases={!r}{})' \ - .format(self.__class__.__name__, - self.id, - self.custom_name, - self.canonical_name, - self.aliases, - kwargs) + return (f'{self.__class__.__name__}(id={self.id}, ' + f'custom_name={self.custom_name!r}, ' + f'canonical_name={self.canonical_name!r}, ' + f'aliases={self.aliases!r}{kwargs})') @staticmethod def default_case(id, default_case=None): @@ -303,8 +298,7 @@ def builtin_namespaces(cls, case: str = 'first-letter'): @staticmethod def normalize_name(name): - """ - Remove an optional colon before and after name. + """Remove an optional colon before and after name. TODO: reject illegal characters. """ @@ -329,8 +323,7 @@ def normalize_name(name): class NamespacesDict(Mapping): - """ - An immutable dictionary containing the Namespace instances. + """An immutable dictionary containing the Namespace instances. It adds a deprecation message when called as the 'namespaces' property of APISite was callable. @@ -350,8 +343,7 @@ def __iter__(self): return iter(self._namespaces) def __getitem__(self, key: Namespace | int | str) -> Namespace: - """ - Get the namespace with the given key. + """Get the namespace with the given key. :param key: namespace key """ @@ -359,8 +351,8 @@ def __getitem__(self, key: Namespace | int | str) -> Namespace: try: return self._namespaces[key] except KeyError: - raise KeyError('{} is not a known namespace. Maybe you should ' - 'clear the api cache.'.format(key)) + raise KeyError(f'{key} is not a known namespace. Maybe you' + ' should clear the api cache.') namespace = self.lookup_name(key) if namespace: @@ -369,8 +361,7 @@ def __getitem__(self, key: Namespace | int | str) -> Namespace: return super().__getitem__(key) def __getattr__(self, attr: Namespace | int | str) -> Namespace: - """ - Get the namespace with the given key. + """Get the namespace with the given key. :param attr: namespace key """ @@ -390,8 +381,7 @@ def __len__(self) -> int: return len(self._namespaces) def lookup_name(self, name: str) -> Namespace | None: - """ - Find the Namespace for a name also checking aliases. + """Find the Namespace for a name also checking aliases. :param name: Name of the namespace. """ @@ -401,8 +391,7 @@ def lookup_name(self, name: str) -> Namespace | None: return self.lookup_normalized_name(name.lower()) def lookup_normalized_name(self, name: str) -> Namespace | None: - """ - Find the Namespace for a name also checking aliases. + """Find the Namespace for a name also checking aliases. The name has to be normalized and must be lower case. @@ -411,8 +400,7 @@ def lookup_normalized_name(self, name: str) -> Namespace | None: return self._namespace_names.get(name) def resolve(self, identifiers) -> list[Namespace]: - """ - Resolve namespace identifiers to obtain Namespace objects. + """Resolve namespace identifiers to obtain Namespace objects. Identifiers may be any value for which int() produces a valid namespace id, except bool, or any string which Namespace.lookup_name @@ -446,8 +434,9 @@ def resolve(self, identifiers) -> list[Namespace]: for ns in identifiers] if NotImplemented in result: - raise TypeError('identifiers contains inappropriate types: {!r}' - .format(identifiers)) + raise TypeError( + f'identifiers contains inappropriate types: {identifiers!r}' + ) # Namespace.lookup_name returns None if the name is not recognised if None in result: diff --git a/pywikibot/site/_obsoletesites.py b/pywikibot/site/_obsoletesites.py index a165bad4e8..bc99cc4121 100644 --- a/pywikibot/site/_obsoletesites.py +++ b/pywikibot/site/_obsoletesites.py @@ -18,6 +18,7 @@ class RemovedSite(BaseSite): class ClosedSite(APISite): + """Site closed to read-only mode.""" def _closed_error(self, notice: str = '') -> None: diff --git a/pywikibot/site/_siteinfo.py b/pywikibot/site/_siteinfo.py index d0e92e1e6a..b720aab2bb 100644 --- a/pywikibot/site/_siteinfo.py +++ b/pywikibot/site/_siteinfo.py @@ -20,8 +20,7 @@ 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 @@ -90,8 +89,7 @@ def _post_process(prop, data) -> None: data[p] = p in data def _get_siteinfo(self, prop, expiry) -> dict: - """ - Retrieve a siteinfo property. + """Retrieve a siteinfo property. All properties which the site doesn't support contain the default value. Because pre-1.12 no data was @@ -144,15 +142,15 @@ def warn_handler(mod, message) -> bool: "one property is unknown: '{}'" .format("', '".join(props))) results = {} - for prop in props: - results.update(self._get_siteinfo(prop, expiry)) + for p in props: + results.update(self._get_siteinfo(p, expiry)) return results raise result = {} if invalid_properties: - for prop in invalid_properties: - result[prop] = (EMPTY_DEFAULT, False) + for invalid_prop in invalid_properties: + result[invalid_prop] = (EMPTY_DEFAULT, False) pywikibot.log("Unable to get siprop(s) '{}'" .format("', '".join(invalid_properties))) @@ -179,8 +177,7 @@ def _is_expired(cache_date, expire): return cache_date + expire < pywikibot.Timestamp.nowutc() def _get_general(self, key: str, expiry): - """ - Return a siteinfo property which is loaded by default. + """Return a siteinfo property which is loaded by default. The property 'general' will be queried if it wasn't yet or it's forced. Additionally all uncached default properties are queried. This way @@ -229,8 +226,7 @@ def get( cache: bool = True, expiry: datetime.datetime | float | bool = False ) -> Any: - """ - Return a siteinfo property. + """Return a siteinfo property. It will never throw an APIError if it only stated, that the siteinfo property doesn't exist. Instead it will use the default value. @@ -325,8 +321,7 @@ def is_recognised(self, key: str) -> bool | None: return None if time is None else bool(time) def get_requested_time(self, key: str): - """ - Return when 'key' was successfully requested from the server. + """Return when 'key' was successfully requested from the server. If the property is actually in the siprop 'general' it returns the last request from the 'general' siprop. diff --git a/pywikibot/site/_upload.py b/pywikibot/site/_upload.py index 7adc5fc7e8..0386764aed 100644 --- a/pywikibot/site/_upload.py +++ b/pywikibot/site/_upload.py @@ -199,9 +199,9 @@ def ignore_warnings(warnings): if (offset is not False and offset is not True and offset > file_size): raise ValueError( - 'For the file key "{}" the offset was set to {} ' - 'while the file is only {} bytes large.' - .format(file_key, offset, file_size)) + f'For the file key "{file_key}" the offset was set to ' + f'{offset} while the file is only {file_size} bytes large.' + ) if verify_stash or offset is True: if not file_key: @@ -338,10 +338,10 @@ def ignore_warnings(warnings): # every time ApiError. if offset != new_offset: pywikibot.log( - 'Old offset: {}; Returned ' - 'offset: {}; Chunk size: {}' - .format(offset, new_offset, - len(chunk))) + f'Old offset: {offset}; Returned ' + f'offset: {new_offset}; Chunk size: ' + f'{len(chunk)}' + ) pywikibot.warning('Attempting to correct ' 'automatically from ' 'offset mismatch error.') @@ -390,11 +390,11 @@ def ignore_warnings(warnings): if 'offset' in data: new_offset = int(data['offset']) if offset + len(chunk) != new_offset: - pywikibot.log('Old offset: {}; Returned ' - 'offset: {}; Chunk size: {}' - .format(offset, - new_offset, - len(chunk))) + pywikibot.log( + f'Old offset: {offset}; Returned ' + f'offset: {new_offset}; Chunk size: ' + f'{len(chunk)}' + ) pywikibot.warning('Unexpected offset.') offset = new_offset else: @@ -413,23 +413,21 @@ def ignore_warnings(warnings): raise Error('Unrecognized result: {result}' .format_map(data)) - else: # not chunked upload - if file_key: - final_request['filekey'] = file_key - else: - file_contents = f.read() - filetype = (mimetypes.guess_type(self.filename)[0] - or 'application/octet-stream') - final_request.mime = { - 'file': (file_contents, filetype.split('/'), - {'filename': mime_filename}) - } + elif file_key: + final_request['filekey'] = file_key + else: + file_contents = f.read() + filetype = (mimetypes.guess_type(self.filename)[0] + or 'application/octet-stream') + final_request.mime = { + 'file': (file_contents, filetype.split('/'), + {'filename': mime_filename}) + } else: # upload by URL if not self.site.has_right('upload_by_url'): - raise Error( - "User '{}' is not authorized to upload by URL on site {}." - .format(self.site.user(), self)) + raise Error(f"User '{self.site.user()}' is not authorized to " + f'upload by URL on site {self}.') final_request = self.site.simple_request( action='upload', filename=file_page_title, url=self.url, comment=self.comment, text=self.text, token=token) diff --git a/pywikibot/site_detect.py b/pywikibot/site_detect.py index 5d3a61cbe8..728f9e26ad 100644 --- a/pywikibot/site_detect.py +++ b/pywikibot/site_detect.py @@ -39,8 +39,7 @@ class MWSite: """Minimal wiki site class.""" def __init__(self, fromurl, **kwargs) -> None: - """ - Initializer. + """Initializer. :raises pywikibot.exceptions.ServerError: a server error occurred while loading the site @@ -240,28 +239,26 @@ def set_api_url(self, url) -> None: if not new_parsed_url.scheme or not new_parsed_url.netloc: new_parsed_url = urlparse( - '{}://{}{}'.format( - new_parsed_url.scheme or self.url.scheme, - new_parsed_url.netloc or self.url.netloc, - new_parsed_url.path)) - else: - if self._parsed_url: - # allow upgrades to https, but not downgrades - if self._parsed_url.scheme == 'https' \ - and new_parsed_url.scheme != self._parsed_url.scheme: - return + f'{new_parsed_url.scheme or self.url.scheme}://' + f'{new_parsed_url.netloc or self.url.netloc}' + f'{new_parsed_url.path}' + ) + elif self._parsed_url: + # allow upgrades to https, but not downgrades + if self._parsed_url.scheme == 'https' \ + and new_parsed_url.scheme != self._parsed_url.scheme: + return - # allow http://www.brickwiki.info/ vs http://brickwiki.info/ - if (new_parsed_url.netloc in self._parsed_url.netloc - or self._parsed_url.netloc in new_parsed_url.netloc): - return + # allow http://www.brickwiki.info/ vs http://brickwiki.info/ + if (new_parsed_url.netloc in self._parsed_url.netloc + or self._parsed_url.netloc in new_parsed_url.netloc): + return - assert new_parsed_url == self._parsed_url, '{} != {}'.format( - self._parsed_url, new_parsed_url) + assert new_parsed_url == self._parsed_url, \ + f'{self._parsed_url} != {new_parsed_url}' self._parsed_url = new_parsed_url - self.server = '{url.scheme}://{url.netloc}'.format( - url=self._parsed_url) + self.server = f'{self._parsed_url.scheme}://{self._parsed_url.netloc}' self.scriptpath = self._parsed_url.path def handle_starttag(self, tag, attrs) -> None: diff --git a/pywikibot/specialbots/_upload.py b/pywikibot/specialbots/_upload.py index c10f519639..69abe2ac90 100644 --- a/pywikibot/specialbots/_upload.py +++ b/pywikibot/specialbots/_upload.py @@ -19,10 +19,10 @@ import requests import pywikibot -import pywikibot.comms.http as http from pywikibot import config from pywikibot.backports import Callable from pywikibot.bot import BaseBot, QuitKeyboardInterrupt +from pywikibot.comms import http from pywikibot.exceptions import APIError, FatalServerError, NoPageError @@ -216,7 +216,7 @@ def _handle_warning(self, warning: str) -> bool | None: return None if self.aborts is not True else False def _handle_warnings(self, warnings): - messages = '\n'.join('{0.code}: {0.info}'.format(warning) + messages = '\n'.join(f'{warning.code}: {warning.info}' for warning in sorted(warnings, key=lambda w: w.code)) if len(warnings) > 1: @@ -375,16 +375,14 @@ def abort_on_warn(self, warn_code): return self.aborts is True or warn_code in self.aborts def ignore_on_warn(self, warn_code: str): - """ - Determine if the warning message should be ignored. + """Determine if the warning message should be ignored. :param warn_code: The warning message """ return self.ignore_warning is True or warn_code in self.ignore_warning def upload_file(self, file_url: str) -> str | None: - """ - Upload the image at file_url to the target wiki. + """Upload the image at file_url to the target wiki. .. seealso:: :api:`Upload` diff --git a/pywikibot/textlib.py b/pywikibot/textlib.py index 4ab11ca2f3..00890478c1 100644 --- a/pywikibot/textlib.py +++ b/pywikibot/textlib.py @@ -112,8 +112,7 @@ def to_local_digits(phrase: str | int, lang: str) -> str: - """ - Change Latin digits based on language to localized version. + """Change Latin 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. @@ -236,10 +235,10 @@ def ignore_case(string: str) -> str: def _tag_pattern(tag_name: str) -> str: """Return a tag pattern for the given tag name.""" return ( - r'<{0}(?:>|\s+[^>]*(?<!/)>)' # start tag + rf'<{ignore_case(tag_name)}(?:>|\s+[^>]*(?<!/)>)' # start tag r'[\s\S]*?' # contents - r'</{0}\s*>' # end tag - .format(ignore_case(tag_name))) + rf'</{ignore_case(tag_name)}\s*>' # end tag + ) def _tag_regex(tag_name: str): @@ -380,8 +379,7 @@ def replaceExcept(text: str, marker: str = '', site: pywikibot.site.BaseSite | None = None, count: int = 0) -> str: - """ - Return text with *old* replaced by *new*, ignoring specified types of text. + """Return text with *old* replaced by *new*, ignoring specified text types. Skip occurrences of *old* within *exceptions*; e.g. within nowiki tags or HTML comments. If *caseInsensitive* is true, then use case @@ -500,8 +498,7 @@ def removeDisabledParts(text: str, include: Container | None = None, site: pywikibot.site.BaseSite | None = None ) -> str: - """ - Return text without portions where wiki markup is disabled. + """Return text without portions where wiki markup is disabled. Parts that will be removed by default are: @@ -619,8 +616,7 @@ def handle_endtag(self, tag) -> None: def isDisabled(text: str, index: int, tags=None) -> bool: - """ - Return True if text[index] is disabled, e.g. by a comment or nowiki tags. + """Return True if text[index] is disabled, e.g. by a comment or nowiki tag. For the tags parameter, see :py:obj:`removeDisabledParts`. """ @@ -643,8 +639,7 @@ def findmarker(text: str, startwith: str = '@@', def expandmarker(text: str, marker: str = '', separator: str = '') -> str: - """ - Return a marker expanded whitespace and the separator. + """Return a marker expanded whitespace and the separator. It searches for the first occurrence of the marker and gets the combination of the separator and whitespace directly before it. @@ -737,9 +732,9 @@ def replace_callable(link, text, groups, rng): def check_classes(replacement): """Normalize the replacement into a list.""" if not isinstance(replacement, (pywikibot.Page, pywikibot.Link)): - raise ValueError('The replacement must be None, False, ' - 'a sequence, a Link or a str but ' - 'is "{}"'.format(type(replacement))) + raise ValueError('The replacement must be None, False, a' + ' sequence, a Link or a str but is ' + f'"{type(replacement)}"') def title_section(link) -> str: title = link.title @@ -748,8 +743,8 @@ def title_section(link) -> str: return title if not isinstance(site, pywikibot.site.BaseSite): - raise ValueError('The "site" argument must be a BaseSite not {}.' - .format(type(site).__name__)) + raise ValueError('The "site" argument must be a BaseSite not ' + f'{type(site).__name__}.') if isinstance(replace, Sequence): if len(replace) != 2: @@ -758,8 +753,8 @@ def title_section(link) -> str: replace_list = [to_link(replace[0]), replace[1]] if not isinstance(replace_list[0], pywikibot.Link): raise ValueError( - 'The original value must be either str, Link or Page ' - 'but is "{}"'.format(type(replace_list[0]))) + 'The original value must be either str, Link or Page but is ' + f'"{type(replace_list[0])}"') if replace_list[1] is not False and replace_list[1] is not None: if isinstance(replace_list[1], str): replace_list[1] = pywikibot.Page(site, replace_list[1]) @@ -769,7 +764,8 @@ def title_section(link) -> str: linktrail = site.linktrail() link_pattern = re.compile( r'\[\[(?P<title>.*?)(#(?P<section>.*?))?(\|(?P<label>.*?))?\]\]' - r'(?P<linktrail>{})'.format(linktrail)) + rf'(?P<linktrail>{linktrail})' + ) extended_label_pattern = re.compile(fr'(.*?\]\])({linktrail})') linktrail = re.compile(linktrail) curpos = 0 @@ -1116,10 +1112,8 @@ def extract_sections( cat_regex, interwiki_regex = get_regexes(['category', 'interwiki'], site) langlink_pattern = interwiki_regex.pattern.replace(':?', '') last_section_content = sections[-1].content if sections else header - footer = re.search( - r'({})*\Z'.format(r'|'.join((langlink_pattern, - cat_regex.pattern, r'\s'))), - last_section_content).group().lstrip() + footer = re.search(fr'({langlink_pattern}|{cat_regex.pattern}|\s)*\Z', + last_section_content).group().lstrip() if footer: if sections: @@ -1239,8 +1233,8 @@ def removeLanguageLinks(text: str, site=None, marker: str = '') -> str: + list(site.family.obsolete.keys())) if not languages: return text - interwikiR = re.compile(r'\[\[({})\s?:[^\[\]\n]*\]\][\s]*' - .format(languages), re.IGNORECASE) + interwikiR = re.compile(rf'\[\[({languages})\s?:[^\[\]\n]*\]\][\s]*', + re.IGNORECASE) text = replaceExcept(text, interwikiR, '', ['comment', 'math', 'nowiki', 'pre', 'syntaxhighlight'], @@ -1251,8 +1245,7 @@ def removeLanguageLinks(text: str, site=None, marker: str = '') -> str: def removeLanguageLinksAndSeparator(text: str, site=None, marker: str = '', separator: str = '') -> str: - """ - Return text with inter-language links and preceding separators removed. + """Return text with inter-language links and preceding separators removed. If a link to an unknown language is encountered, a warning is printed. @@ -1399,7 +1392,7 @@ def interwikiFormat(links: dict, insite=None) -> str: :param links: interwiki links to be formatted :type links: dict with the Site objects as keys, and Page - or Link objects as values. # noqa: DAR103 + or Link objects as values. :param insite: site the interwiki links will be formatted for (defaulting to the current site). :type insite: BaseSite @@ -1473,8 +1466,9 @@ def getCategoryLinks(text: str, site=None, # and HTML comments text = removeDisabledParts(text, include=include or []) catNamespace = '|'.join(site.namespaces.CATEGORY) - R = re.compile(r'\[\[\s*(?P<namespace>{})\s*:\s*(?P<rest>.+?)\]\]' - .format(catNamespace), re.I) + R = re.compile( + rf'\[\[\s*(?P<namespace>{catNamespace})\s*:\s*(?P<rest>.+?)\]\]', re.I + ) for match in R.finditer(text): match_rest = match['rest'] if expand_text and '{{' in match_rest: @@ -1516,8 +1510,7 @@ def removeCategoryLinks(text: str, site=None, marker: str = '') -> str: if site is None: site = pywikibot.Site() catNamespace = '|'.join(site.namespaces.CATEGORY) - categoryR = re.compile(r'\[\[\s*({})\s*:.*?\]\]\s*' - .format(catNamespace), re.I) + categoryR = re.compile(rf'\[\[\s*({catNamespace})\s*:.*?\]\]\s*', re.I) text = replaceExcept(text, categoryR, '', ['comment', 'includeonly', 'math', 'nowiki', 'pre', 'syntaxhighlight'], @@ -1532,8 +1525,7 @@ def removeCategoryLinks(text: str, site=None, marker: str = '') -> str: def removeCategoryLinksAndSeparator(text: str, site=None, marker: str = '', separator: str = '') -> str: - """ - Return text with category links and preceding separators removed. + """Return text with category links and preceding separators removed. :param text: The text that needs to be modified. :param site: The site that the text is coming from. @@ -1556,8 +1548,7 @@ def removeCategoryLinksAndSeparator(text: str, site=None, marker: str = '', def replaceCategoryInPlace(oldtext, oldcat, newcat, site=None, add_only: bool = False) -> str: - """ - Replace old category with new one and return the modified text. + """Replace old category with new one and return the modified text. :param oldtext: Content of the old category :param oldcat: pywikibot.Category object of the old category @@ -1576,13 +1567,12 @@ def replaceCategoryInPlace(oldtext, oldcat, newcat, site=None, # title might contain regex special characters title = case_escape(site.namespaces[14].case, title, underscore=True) - categoryR = re.compile(r'\[\[\s*({})\s*:\s*{}[\s\u200e\u200f]*' - r'((?:\|[^]]+)?\]\])' - .format(catNamespace, title), re.I) + categoryR = re.compile( + rf'\[\[\s*({catNamespace})\s*:\s*{title}[\s\u200e\u200f]*' + r'((?:\|[^]]+)?\]\])', re.IGNORECASE) categoryRN = re.compile( - r'^[^\S\n]*\[\[\s*({})\s*:\s*{}[\s\u200e\u200f]*' - r'((?:\|[^]]+)?\]\])[^\S\n]*\n' - .format(catNamespace, title), re.I | re.M) + rf'^[^\S\n]*\[\[\s*({catNamespace})\s*:\s*{title}[\s\u200e\u200f]*' + r'((?:\|[^]]+)?\]\])[^\S\n]*\n', re.IGNORECASE | re.MULTILINE) exceptions = ['comment', 'math', 'nowiki', 'pre', 'syntaxhighlight'] if newcat is None: # First go through and try the more restrictive regex that removes @@ -1595,16 +1585,16 @@ def replaceCategoryInPlace(oldtext, oldcat, newcat, site=None, elif add_only: text = replaceExcept( oldtext, categoryR, - '{}\n{}'.format( - oldcat.title(as_link=True, allow_interwiki=False), - newcat.title(as_link=True, allow_interwiki=False)), - exceptions, site=site) + f'{oldcat.title(as_link=True, allow_interwiki=False)}\n' + f'{newcat.title(as_link=True, allow_interwiki=False)}', + exceptions, site=site + ) else: - text = replaceExcept(oldtext, categoryR, - '[[{}:{}\\2' - .format(site.namespace(14), - newcat.title(with_ns=False)), - exceptions, site=site) + text = replaceExcept( + oldtext, categoryR, + f'[[{site.namespace(14)}:{newcat.title(with_ns=False)}\\2', + exceptions, site=site + ) return text @@ -1613,8 +1603,7 @@ def replaceCategoryLinks(oldtext: str, new: Iterable, site: pywikibot.site.BaseSite | None = None, add_only: bool = False) -> str: - """ - Replace all existing category links with new category links. + """Replace all existing category links with new category links. :param oldtext: The text that needs to be replaced. :param new: Should be a list of Category objects or strings @@ -1630,7 +1619,7 @@ def replaceCategoryLinks(oldtext: str, if site is None: site = pywikibot.Site() if re.search(r'\{\{ *(' + r'|'.join(site.getmagicwords('defaultsort')) - + r')', oldtext, flags=re.I): + + r')', oldtext, flags=re.IGNORECASE): separator = '\n' else: separator = site.family.category_text_separator @@ -1677,15 +1666,15 @@ def replaceCategoryLinks(oldtext: str, if site.sitename == 'wikipedia:de': personendaten = re.compile(r'\{\{ *Personendaten.*?\}\}', - re.I | re.DOTALL) + re.IGNORECASE | re.DOTALL) under_categories.append(personendaten) if site.sitename == 'wikipedia:yi': - stub = re.compile(r'\{\{.*?שטומף *\}\}', re.I) + stub = re.compile(r'\{\{.*?שטומף *\}\}', re.IGNORECASE) under_categories.append(stub) if site.family.name == 'wikipedia' and site.code in ('simple', 'en'): - stub = re.compile(r'\{\{.*?stub *\}\}', re.I) + stub = re.compile(r'\{\{.*?stub *\}\}', re.IGNORECASE) under_categories.append(stub) if under_categories: @@ -1765,10 +1754,9 @@ def compileLinkR(withoutBracketed: bool = False, onlyBracketed: bool = False): # not allowed inside links. For example, in this wiki text: # ''Please see https://www.example.org.'' # .'' shouldn't be considered as part of the link. - regex = r'(?P<url>http[s]?://[^{notInside}]*?[^{notAtEnd}]' \ - r'(?=[{notAtEnd}]*\'\')|http[s]?://[^{notInside}]*' \ - r'[^{notAtEnd}])'.format(notInside=notInside, - notAtEnd=notAtEnd) + regex = rf'(?P<url>http[s]?://[^{notInside}]*?[^{notAtEnd}]' \ + rf'(?=[{notAtEnd}]*\'\')|http[s]?://[^{notInside}]*' \ + rf'[^{notAtEnd}])' if withoutBracketed: regex = r'(?<!\[)' + regex @@ -1866,8 +1854,7 @@ def explicit(param): def extract_templates_and_params_regex_simple(text: str): - """ - Extract top-level templates with params using only a simple regex. + """Extract top-level templates with params using only a simple regex. This function uses only a single regex, and returns an entry for each template called at the top-level of the wikitext. @@ -1922,8 +1909,7 @@ def glue_template_and_params(template_and_params) -> str: # -------------------------- def does_text_contain_section(pagetext: str, section: str) -> bool: - """ - Determine whether the page text contains the given section title. + """Determine whether the page text contains the given section title. It does not care whether a section string may contain spaces or underlines. Both will match. @@ -1963,6 +1949,7 @@ def reformat_ISBNs(text: str, match_func) -> str: class TimeStripperPatterns(NamedTuple): + """Hold precompiled timestamp patterns for :class:`TimeStripper`. Attribute order is important to avoid mismatch when searching. @@ -2143,8 +2130,7 @@ def _last_match_and_replace(self, m = all_matches[-1] def marker(m: Match[str]): - """ - Replace exactly the same number of matched characters. + """Replace exactly the same number of matched characters. Same number of chars shall be replaced, in order to be able to compare pos for matches reliably (absolute pos of a match @@ -2186,8 +2172,7 @@ def _valid_date_dict_positions(dateDict) -> bool: return not min_pos < time_pos < max_pos def timestripper(self, line: str) -> pywikibot.Timestamp | None: - """ - Find timestamp in line and convert it to time zone aware datetime. + """Find timestamp in line and convert it to time zone aware datetime. All the following items must be matched, otherwise None is returned: -. year, month, hour, time, day, minute, tzinfo diff --git a/pywikibot/throttle.py b/pywikibot/throttle.py index d50dafb873..20680a80c2 100644 --- a/pywikibot/throttle.py +++ b/pywikibot/throttle.py @@ -33,6 +33,7 @@ class ProcEntry(NamedTuple): + """ProcEntry namedtuple.""" module_id: str diff --git a/pywikibot/time.py b/pywikibot/time.py index 002ce516f6..e8910c871f 100644 --- a/pywikibot/time.py +++ b/pywikibot/time.py @@ -318,8 +318,7 @@ def fromtimestampformat(cls, return cls._from_mw(ts) def isoformat(self, sep: str = 'T') -> str: # type: ignore[override] - """ - Convert object to an ISO 8601 timestamp accepted by MediaWiki. + """Convert object to an ISO 8601 timestamp accepted by MediaWiki. datetime.datetime.isoformat does not postfix the ISO formatted date with a 'Z' unless a timezone is included, which causes MediaWiki @@ -332,8 +331,7 @@ def totimestampformat(self) -> str: return self.strftime(self.mediawikiTSFormat) def posix_timestamp(self) -> float: - """ - Convert object to a POSIX timestamp. + """Convert object to a POSIX timestamp. See Note in datetime.timestamp(). @@ -504,8 +502,7 @@ def now(cls, tz=None) -> Timestamp: class TZoneFixedOffset(datetime.tzinfo): - """ - Class building tzinfo objects for fixed-offset time zones. + """Class building tzinfo objects for fixed-offset time zones. :param offset: a number indicating fixed offset in minutes east from UTC :param name: a string with name of the timezone @@ -530,19 +527,16 @@ def dst(self, dt: datetime.datetime | None) -> datetime.timedelta: def __repr__(self) -> str: """Return the internal representation of the timezone.""" - return '{}({}, {})'.format( - self.__class__.__name__, - self._offset.days * 86400 + self._offset.seconds, - self._name - ) + return (f'{type(self).__name__}' + f'({self._offset.days * 86400 + self._offset.seconds}, ' + f'{self._name})') def str2timedelta( string: str, timestamp: datetime.datetime | None = None, ) -> datetime.timedelta: - """ - Return a timedelta for a shorthand duration. + """Return a timedelta for a shorthand duration. :param string: a string defining a time period: @@ -575,8 +569,7 @@ def str2timedelta( def parse_duration(string: str) -> tuple[str, int]: - """ - Return the key and duration extracted from the string. + """Return the key and duration extracted from the string. :param string: a string defining a time period diff --git a/pywikibot/titletranslate.py b/pywikibot/titletranslate.py index ace5183e67..16720042cc 100644 --- a/pywikibot/titletranslate.py +++ b/pywikibot/titletranslate.py @@ -12,26 +12,37 @@ def translate( page=None, - hints=(), + hints: list[str] | None = None, auto: bool = True, removebrackets: bool = False, site=None ) -> list[pywikibot.Link]: - """ - Return a list of links to pages on other sites based on hints. + """Return a list of links to pages on other sites based on hints. - Entries for single page titles list those pages. Page titles for entries - such as "all:" or "xyz:" or "20:" are first built from the page title of - 'page' and then listed. When 'removebrackets' is True, a trailing pair of - brackets and the text between them is removed from the page title. - If 'auto' is true, known year and date page titles are autotranslated - to all known target languages and inserted into the list. - """ - result = set() + Entries for single page titles list those pages. Page titles for + entries such as "all:" or "xyz:" or "20:" are first built from the + page title of 'page' and then listed. - assert page or site + .. versionchanged:: 9.6 + Raise ``RuntimeError`` instead of ``AssertionError`` if neither + *page* nor *site* parameter is given. + + :param auto: If true, known year and date page titles are + autotranslated to all known target languages and inserted into + the list. + :param removebrackets: If True, a trailing pair of brackets and the + text between them is removed from the page title. + :raises RuntimeError: Either page or site parameter must be given. + """ + if not page and not site: + raise RuntimeError( + 'Either page or site parameter must be given with translate()') site = site or page.site + result = set() + + if hints is None: + hints = [] for h in hints: # argument may be given as -hint:xy where xy is a language code @@ -52,13 +63,13 @@ def translate( codes = site.family.language_groups.get(codes, codes.split(',')) for newcode in codes: - if newcode in site.languages(): + if newcode in site.codes: if newcode != site.code: ns = page.namespace() if page else 0 - x = pywikibot.Link(newname, - site.getSite(code=newcode), - default_namespace=ns) - result.add(x) + link = pywikibot.Link(newname, + site.getSite(code=newcode), + default_namespace=ns) + result.add(link) elif config.verbose_output: pywikibot.info(f'Ignoring unknown language code {newcode}') @@ -66,20 +77,17 @@ def translate( # existing interwiki links. if auto and page: # search inside all dictionaries for this link - sitelang = page.site.lang - dict_name, value = date.getAutoFormat(sitelang, page.title()) + dict_name, value = page.autoFormat() if dict_name: - pywikibot.info( - 'TitleTranslate: {} was recognized as {} with value {}' - .format(page.title(), dict_name, value)) + pywikibot.info(f'TitleTranslate: {page.title()} was recognized as ' + f'{dict_name} with value {value}') for entry_lang, entry in date.formats[dict_name].items(): - if entry_lang not in site.languages(): + if entry_lang not in site.codes: continue - if entry_lang != sitelang: - newname = entry(value) - x = pywikibot.Link( - newname, - pywikibot.Site(code=entry_lang, - fam=site.family)) - result.add(x) + + if entry_lang != page.site.lang: + link = pywikibot.Link(entry(value), + site.getSite(entry_lang)) + result.add(link) + return list(result) diff --git a/pywikibot/tools/__init__.py b/pywikibot/tools/__init__.py index 00ce4c35c5..ba49aa0681 100644 --- a/pywikibot/tools/__init__.py +++ b/pywikibot/tools/__init__.py @@ -150,8 +150,8 @@ def has_module(module: str, version: str | None = None) -> bool: module_version = packaging.version.Version(metadata_version) if module_version < required_version: - warn('Module version {} is lower than requested version {}' - .format(module_version, required_version), ImportWarning) + warn(f'Module version {module_version} is lower than requested ' + f'version {required_version}', ImportWarning) return False return True @@ -159,8 +159,7 @@ def has_module(module: str, version: str | None = None) -> bool: class classproperty: # noqa: N801 - """ - Descriptor class to access a class method as a property. + """Descriptor class to access a class method as a property. This class may be used as a decorator:: @@ -178,12 +177,28 @@ def bar(cls): # a class property method """ def __init__(self, cls_method) -> None: - """Hold the class method.""" + """Initializer: hold the class method and documentation.""" self.method = cls_method - self.__doc__ = self.method.__doc__ + self.__annotations__ = self.method.__annotations__ + doc = self.method.__doc__ + self.__doc__ = f':class:`classproperty<tools.classproperty>` {doc}' + + rtype = self.__annotations__.get('return') + if rtype: + lines = doc.splitlines() + + if len(lines) > 2 and PYTHON_VERSION < (3, 13): + spaces = ' ' * re.search('[^ ]', lines[2]).start() + else: + spaces = '' + + self.__doc__ += f'\n\n{spaces}:rtype: {rtype}' def __get__(self, instance, owner): """Get the attribute of the owner class by its method.""" + if SPHINX_RUNNING: + return self + return self.method(owner) @@ -218,7 +233,7 @@ def __init__( the start of the path to the warning module must match. (case-sensitive) """ - self.message_match = re.compile(message, re.I).match + self.message_match = re.compile(message, re.IGNORECASE).match self.category = category self.filename_match = re.compile(filename).match super().__init__(record=True) @@ -297,8 +312,7 @@ def __ne__(self, other): def first_lower(string: str) -> str: - """ - Return a string with the first character uncapitalized. + """Return a string with the first character uncapitalized. Empty strings are supported. The original string is not changed. @@ -313,8 +327,7 @@ def first_lower(string: str) -> str: def first_upper(string: str) -> str: - """ - Return a string with the first character capitalized. + """Return a string with the first character capitalized. Empty strings are supported. The original string is not changed. @@ -419,8 +432,7 @@ def normalize_username(username) -> str | None: @total_ordering class MediaWikiVersion: - """ - Version object to allow comparing 'wmf' versions with normal ones. + """Version object to allow comparing 'wmf' versions with normal ones. The version mainly consist of digits separated by periods. After that is a suffix which may only be 'wmf<number>', 'alpha', @@ -449,8 +461,7 @@ class MediaWikiVersion: r'(\d+(?:\.\d+)+)(-?wmf\.?(\d+)|alpha|beta(\d+)|-?rc\.?(\d+)|.*)?') def __init__(self, version_str: str) -> None: - """ - Initializer. + """Initializer. :param version_str: version to parse """ @@ -496,8 +507,8 @@ def from_generator(generator: str) -> MediaWikiVersion: prefix = 'MediaWiki ' if not generator.startswith(prefix): - raise ValueError('Generator string ({!r}) must start with ' - '"{}"'.format(generator, prefix)) + raise ValueError(f'Generator string ({generator!r}) must start ' + f'with "{prefix}"') return MediaWikiVersion(generator[len(prefix):]) @@ -518,8 +529,8 @@ def __lt__(self, other: Any) -> bool: if isinstance(other, str): other = MediaWikiVersion(other) elif not isinstance(other, MediaWikiVersion): - raise TypeError("Comparison between 'MediaWikiVersion' and '{}' " - 'unsupported'.format(type(other).__name__)) + raise TypeError(f"Comparison between 'MediaWikiVersion' and " + f"'{type(other).__name__}' unsupported") if self.version != other.version: return self.version < other.version @@ -527,8 +538,7 @@ def __lt__(self, other: Any) -> bool: def open_archive(filename: str, mode: str = 'rb', use_extension: bool = True): - """ - Open a file and uncompress it if needed. + """Open a file and uncompress it if needed. This function supports bzip2, gzip, 7zip, lzma, and xz as compression containers. It uses the packages available in the @@ -587,9 +597,9 @@ def open_archive(filename: str, mode: str = 'rb', use_extension: bool = True): with open(filename, 'rb') as f: magic_number = f.read(8) - for pattern in extension_map: + for pattern, ext in extension_map.items(): if magic_number.startswith(pattern): - extension = extension_map[pattern] + extension = ext break else: extension = '' @@ -632,8 +642,7 @@ def open_archive(filename: str, mode: str = 'rb', use_extension: bool = True): def merge_unique_dicts(*args, **kwargs): - """ - Return a merged dict and make sure that the original dicts keys are unique. + """Return a merged dict and make sure that the original keys are unique. The positional arguments are the dictionaries to be merged. It is also possible to define an additional dict using the keyword diff --git a/pywikibot/tools/_deprecate.py b/pywikibot/tools/_deprecate.py index bff2a18c8a..c1e6512871 100644 --- a/pywikibot/tools/_deprecate.py +++ b/pywikibot/tools/_deprecate.py @@ -109,8 +109,7 @@ def get_wrapper_depth(wrapper): def add_full_name(obj): - """ - A decorator to add __full_name__ to the function being decorated. + """A decorator to add __full_name__ to the function being decorated. This should be done for all decorators used in pywikibot, as any decorator that does not add __full_name__ will prevent other @@ -522,8 +521,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: def remove_last_args(arg_names): - """ - Decorator to declare all args additionally provided deprecated. + """Decorator to declare all args additionally provided deprecated. All positional arguments appearing after the normal arguments are marked deprecated. It marks also all keyword arguments present in arg_names as @@ -591,8 +589,7 @@ def redirect_func(target, *, class_name: str | None = None, since: str = '', future_warning: bool = True): - """ - Return a function which can be used to redirect to 'target'. + """Return a function which can be used to redirect to 'target'. It also acts like marking that function deprecated and copies all parameters. @@ -675,8 +672,7 @@ def add_deprecated_attr(self, name: str, replacement: Any = None, *, warning_message: str | None = None, since: str = '', future_warning: bool = True): - """ - Add the name to the local deprecated names dict. + """Add the name to the local deprecated names dict. .. versionchanged:: 7.0 ``since`` parameter must be a release number, not a timestamp. diff --git a/pywikibot/tools/chars.py b/pywikibot/tools/chars.py index a9aef1b4ec..de011c0f83 100644 --- a/pywikibot/tools/chars.py +++ b/pywikibot/tools/chars.py @@ -1,13 +1,12 @@ """Character based helper functions (not wiki-dependent).""" # -# (C) Pywikibot team, 2015-2023 +# (C) Pywikibot team, 2015-2024 # # Distributed under the terms of the MIT license. # from __future__ import annotations import re -import sys from contextlib import suppress from urllib.parse import unquote @@ -30,14 +29,7 @@ def contains_invisible(text): def replace_invisible(text): """Replace invisible characters by '<codepoint>'.""" def replace(match) -> str: - match = match.group() - if sys.maxunicode < 0x10ffff and len(match) == 2: - mask = (1 << 10) - 1 - assert ord(match[0]) & ~mask == 0xd800 - assert ord(match[1]) & ~mask == 0xdc00 - codepoint = (ord(match[0]) & mask) << 10 | (ord(match[1]) & mask) - else: - codepoint = ord(match) + codepoint = ord(match.group()) return f'<{codepoint:x}>' return INVISIBLE_REGEX.sub(replace, text) diff --git a/pywikibot/tools/collections.py b/pywikibot/tools/collections.py index cd4d9a9a2d..5ef1cb9c12 100644 --- a/pywikibot/tools/collections.py +++ b/pywikibot/tools/collections.py @@ -1,6 +1,6 @@ """Collections datatypes.""" # -# (C) Pywikibot team, 2014-2023 +# (C) Pywikibot team, 2014-2024 # # Distributed under the terms of the MIT license. # @@ -148,8 +148,7 @@ class CombinedError(KeyError, IndexError): class EmptyDefault(str, Mapping): - """ - A default for a not existing siteinfo property. + """A default for a not existing siteinfo property. It should be chosen if there is no better default known. It acts like an empty collections, so it can be iterated through it safely if treated as a @@ -271,8 +270,8 @@ def send(self, value: Any) -> Any: :raises TypeError: generator property is not a generator """ if not isinstance(self.generator, GeneratorType): - raise TypeError('generator property is not a generator but {}' - .format(type(self.generator).__name__)) + raise TypeError('generator property is not a generator but ' + f'{type(self.generator).__name__}') if not hasattr(self, '_started_gen'): # start the generator self._started_gen = self.generator diff --git a/pywikibot/tools/djvu.py b/pywikibot/tools/djvu.py index 43b1af3459..aa5fc7aef1 100644 --- a/pywikibot/tools/djvu.py +++ b/pywikibot/tools/djvu.py @@ -15,8 +15,7 @@ def _call_cmd(args, lib: str = 'djvulibre') -> tuple: - """ - Tiny wrapper around subprocess.Popen(). + """Tiny wrapper around subprocess.Popen(). :param args: same as Popen() :type args: str or typing.Sequence[string] @@ -52,8 +51,7 @@ class DjVuFile: """ def __init__(self, file: str) -> None: - """ - Initializer. + """Initializer. :param file: filename (including path) to djvu file """ @@ -100,16 +98,14 @@ def wrapper(obj, *args, **kwargs): n = args[0] force = kwargs.get('force', False) if not 1 <= n <= obj.number_of_images(force=force): - raise ValueError('Page {} not in file {} [{}-{}]' - .format(int(n), obj.file, int(n), - int(obj.number_of_images()))) + raise ValueError(f'Page {int(n)} not in file {obj.file} ' + f'[{int(n)}-{int(obj.number_of_images())}]') return fn(obj, *args, **kwargs) return wrapper @check_cache def number_of_images(self, force: bool = False): - """ - Return the number of images in the djvu file. + """Return the number of images in the djvu file. :param force: if True, refresh the cached data """ @@ -122,8 +118,7 @@ def number_of_images(self, force: bool = False): @check_page_number def page_info(self, n: int, force: bool = False): - """ - Return a tuple (id, (size, dpi)) for page n of djvu file. + """Return a tuple (id, (size, dpi)) for page n of djvu file. :param n: page n of djvu file :param force: if True, refresh the cached data @@ -134,9 +129,9 @@ def page_info(self, n: int, force: bool = False): @check_cache def _get_page_info(self, force: bool = False): - """ - Return a dict of tuples (id, (size, dpi)) for all pages of djvu file. + """Return a dict of tuples for all pages of djvu file. + The tuples consist of (id, (size, dpi)). :param force: if True, refresh the cached data """ if not hasattr(self, '_page_info'): @@ -181,8 +176,7 @@ def get_most_common_info(self): @check_cache def has_text(self, force: bool = False): - """ - Test if the djvu file has a text-layer. + """Test if the djvu file has a text-layer. :param force: if True, refresh the cached data """ @@ -212,8 +206,7 @@ def _remove_control_chars(data): @check_page_number @check_cache def get_page(self, n: int, force: bool = False): - """ - Get page n for djvu file. + """Get page n for djvu file. :param n: page n of djvu file :param force: if True, refresh the cached data diff --git a/pywikibot/tools/formatter.py b/pywikibot/tools/formatter.py index 59c3aced71..fa2f3df133 100644 --- a/pywikibot/tools/formatter.py +++ b/pywikibot/tools/formatter.py @@ -69,8 +69,7 @@ def output(self) -> None: @deprecated('New color format pattern like <<color>>colored text<<default>>', since='7.2.0') def color_format(text: str, *args, **kwargs) -> str: - r""" - Do ``str.format`` without having to worry about colors. + r"""Do ``str.format`` without having to worry about colors. It is automatically adding \\03 in front of color fields so it's unnecessary to add them manually. Any other \\03 in the text is diff --git a/pywikibot/tools/itertools.py b/pywikibot/tools/itertools.py index 41fc5cac61..e5911ba265 100644 --- a/pywikibot/tools/itertools.py +++ b/pywikibot/tools/itertools.py @@ -4,7 +4,7 @@ in :mod:`backports` """ # -# (C) Pywikibot team, 2008-2023 +# (C) Pywikibot team, 2008-2024 # # Distributed under the terms of the MIT license. # @@ -65,8 +65,7 @@ def itergroup(iterable, def islice_with_ellipsis(iterable, *args, marker: str = '…'): - """ - Generator which yields the first n elements of the iterable. + """Generator which yields the first n elements of the iterable. If more elements are available and marker is True, it returns an extra string marker as continuation mark. @@ -141,8 +140,8 @@ def intersect_generators(*iterables, allow_duplicates: bool = False): # If any iterable is empty, no pages are going to be returned for source in iterables: if not source: - debug('At least one iterable ({!r}) is empty and execution was ' - 'skipped immediately.'.format(source)) + debug(f'At least one iterable ({source!r}) is empty and execution' + ' was skipped immediately.') return # Item is cached to check that it is found n_gen times @@ -216,8 +215,7 @@ def roundrobin_generators(*iterables) -> Generator[Any, None, None]: def filter_unique(iterable, container=None, key=None, add=None): - """ - Yield unique items from an iterable, omitting duplicates. + """Yield unique items from an iterable, omitting duplicates. By default, to provide uniqueness, it puts the generated items into a set created as a local variable. It only yields items which are not diff --git a/pywikibot/tools/threading.py b/pywikibot/tools/threading.py index 7ea4590def..75395614e7 100644 --- a/pywikibot/tools/threading.py +++ b/pywikibot/tools/threading.py @@ -22,6 +22,7 @@ class RLock: + """Context manager which implements extended reentrant lock objects. This RLock is implicit derived from threading.RLock but provides a @@ -65,7 +66,8 @@ def __repr__(self) -> str: """Representation of tools.RLock instance.""" return repr(self._lock).replace( '_thread.RLock', - '{cls.__module__}.{cls.__class__.__name__}'.format(cls=self)) + f'{self.__module__}.{type(self).__name__}' + ) @property def count(self): diff --git a/pywikibot/userinterfaces/buffer_interface.py b/pywikibot/userinterfaces/buffer_interface.py index 97d7237682..2f508c7656 100644 --- a/pywikibot/userinterfaces/buffer_interface.py +++ b/pywikibot/userinterfaces/buffer_interface.py @@ -3,7 +3,7 @@ .. versionadded:: 6.4 """ # -# (C) Pywikibot team, 2021-2022 +# (C) Pywikibot team, 2021-2024 # # Distributed under the terms of the MIT license. # @@ -75,9 +75,8 @@ def pop_output(self): elif isinstance(record, logging.LogRecord): output.append(record.getMessage()) else: - raise ValueError( - 'BUG: buffer can only contain logs and strings, had {}' - .format(type(record).__name__)) + raise ValueError('Buffer can only contain logs and strings, ' + f'had {type(record).__name__}') return output diff --git a/pywikibot/userinterfaces/gui.py b/pywikibot/userinterfaces/gui.py index 43774f3450..64bcbcfaf0 100644 --- a/pywikibot/userinterfaces/gui.py +++ b/pywikibot/userinterfaces/gui.py @@ -13,7 +13,7 @@ .. seealso:: :mod:`editor` """ # -# (C) Pywikibot team, 2003-2023 +# (C) Pywikibot team, 2003-2024 # # Distributed under the terms of the MIT license. # @@ -23,28 +23,29 @@ from pywikibot.tools import PYTHON_VERSION +# Some Python distributions have tkinter but the underlying _tkinter +# implementation is missing. Thus just import tkinter does not raise +# the exception. Therefore try to import _tkinter. +# Note: idlelib also needs tkinter. try: - import idlelib + import _tkinter # noqa: F401 except ImportError as e: - idlelib = e - ConfigDialog = ReplaceDialog = SearchDialog = object() - idleConf = MultiCallCreator = object() # noqa: N816 + idlelib = tkinter = e + Frame = simpledialog = ScrolledText = object + ConfigDialog = ReplaceDialog = SearchDialog = object + idleConf = MultiCallCreator = object # noqa: N816 else: + import tkinter + from tkinter import Frame, simpledialog + from tkinter.scrolledtext import ScrolledText + + import idlelib from idlelib import replace as ReplaceDialog # noqa: N812 from idlelib import search as SearchDialog # noqa: N812 from idlelib.config import idleConf from idlelib.configdialog import ConfigDialog from idlelib.multicall import MultiCallCreator -try: - import tkinter -except ImportError as e: - tkinter = e - Frame = simpledialog = ScrolledText = object -else: - from tkinter import Frame, simpledialog - from tkinter.scrolledtext import ScrolledText - __all__ = ('EditBoxWindow', 'TextEditor', 'Tkdialog') @@ -219,8 +220,7 @@ def replace_event(self, event=None) -> str: return 'break' def find_all(self, s): - """ - Highlight all occurrences of string s, and select the first one. + """Highlight all occurrences of string s, and select the first one. If the string has already been highlighted, jump to the next occurrence after the current selection. (You cannot go backwards using the @@ -394,8 +394,7 @@ def __init__(self, parent=None, **kwargs) -> None: def edit(self, text: str, jumpIndex: int | None = None, # noqa: N803 highlight: str | None = None) -> str | None: - """ - Provide user with editor to modify text. + """Provide user with editor to modify text. :param text: the text to be edited :param jumpIndex: position at which to put the caret @@ -441,8 +440,7 @@ def config_dialog(self, event=None) -> None: ConfigDialog(self, 'Settings') def pressedOK(self) -> None: # noqa: N802 - """ - Perform OK operation. + """Perform OK operation. Called when user pushes the OK button. Saves the buffer into a variable, and closes the window. @@ -468,9 +466,8 @@ def __init__(self, photo_description, photo, filename) -> None: self.root = tkinter.Tk() # "%dx%d%+d%+d" % (width, height, xoffset, yoffset) - self.root.geometry('{}x{}+10-10' - .format(int(pywikibot.config.tkhorsize), - int(pywikibot.config.tkvertsize))) + self.root.geometry(f'{int(pywikibot.config.tkhorsize)}x' + f'{int(pywikibot.config.tkvertsize)}+10-10') self.root.title(filename) self.photo_description = photo_description diff --git a/pywikibot/userinterfaces/terminal_interface.py b/pywikibot/userinterfaces/terminal_interface.py index 6c3d240ce1..ce0b5c85e5 100644 --- a/pywikibot/userinterfaces/terminal_interface.py +++ b/pywikibot/userinterfaces/terminal_interface.py @@ -1,5 +1,4 @@ -""" -Platform independent terminal interface module. +"""Platform independent terminal interface module. It imports the appropriate operating system specific implementation. """ diff --git a/pywikibot/userinterfaces/terminal_interface_base.py b/pywikibot/userinterfaces/terminal_interface_base.py index c905a246fa..c32a1136f3 100644 --- a/pywikibot/userinterfaces/terminal_interface_base.py +++ b/pywikibot/userinterfaces/terminal_interface_base.py @@ -70,8 +70,7 @@ class UI(ABUIC): split_col_pat = re.compile(r'(\w+);?(\w+)?') def __init__(self) -> None: - """ - Initialize the UI. + """Initialize the UI. This caches the std-streams locally so any attempts to monkey-patch the streams later will not work. @@ -140,13 +139,12 @@ def init_handlers( def encounter_color(self, color, target_stream): """Abstract method to handle the next color encountered.""" - raise NotImplementedError('The {} class does not support ' - 'colors.'.format(self.__class__.__name__)) + raise NotImplementedError(f'The {type(self).__name__} class does not' + ' support colors.') @classmethod def divide_color(cls, color): - """ - Split color label in a tuple. + """Split color label in a tuple. Received color is a string like 'fg_color;bg_color' or 'fg_color'. Returned values are (fg_color, bg_color) or (fg_color, None). @@ -182,9 +180,8 @@ def _write(self, text: str, target_stream) -> None: out, err = self.stdout.name, self.stderr.name except AttributeError: out, err = self.stdout, self.stderr - raise OSError( - 'Target stream {} is neither stdin ({}) nor stderr ({})' - .format(target_stream.name, out, err)) + raise OSError(f'Target stream {target_stream.name} is neither ' + f'stdin ({out}) nor stderr ({err})') def support_color(self, target_stream) -> bool: """Return whether the target stream does support colors.""" @@ -216,8 +213,8 @@ def _print(self, text, target_stream) -> None: # match.split() includes every regex group; for each matched color # fg_col:b_col, fg_col and bg_col are added to the resulting list. len_text_parts = len(text_parts[::4]) - for index, (text, next_color) in enumerate(zip(text_parts[::4], - text_parts[1::4])): + for index, (txt, next_color) in enumerate(zip(text_parts[::4], + text_parts[1::4])): current_color = color_stack[-1] if next_color == 'previous': if len(color_stack) > 1: # keep the last element in the stack @@ -229,15 +226,15 @@ def _print(self, text, target_stream) -> None: if current_color != next_color: colored_line = True if colored_line and not colorized: - if '\n' in text: # Normal end of line - text = text.replace('\n', ' ***\n', 1) + if '\n' in txt: # Normal end of line + txt = txt.replace('\n', ' ***\n', 1) colored_line = False elif index == len_text_parts - 1: # Or end of text - text += ' ***' + txt += ' ***' colored_line = False # print the text up to the tag. - self._write(text, target_stream) + self._write(txt, target_stream) if current_color != next_color and colorized: # set the new color, but only if they change @@ -320,10 +317,10 @@ def stream_output(self, text, targetStream=None) -> None: # transliteration was successful. The replacement # could consist of multiple letters. # mark the transliterated letters in yellow. - transliteratedText = ''.join((transliteratedText, - '<<lightyellow>>', - transliterated, - '<<previous>>')) + transliteratedText = ( + f'{transliteratedText}' + f'<<lightyellow>>{transliterated}<<previous>>' + ) # memorize if we replaced a single letter by multiple # letters. if transliterated: @@ -347,8 +344,7 @@ def input(self, question: str, password: bool = False, default: str | None = '', force: bool = False) -> str: - """ - Ask the user a question and return the answer. + """Ask the user a question and return the answer. Works like raw_input(), but returns a unicode string instead of ASCII. @@ -410,7 +406,7 @@ def _input_reraise_cntl_c(self, password): else: text = self._raw_input() except KeyboardInterrupt: - raise QuitKeyboardInterrupt() + raise QuitKeyboardInterrupt except UnicodeDecodeError: return None # wrong terminal encoding, T258143 return text @@ -482,8 +478,8 @@ def output_option(option, before_question) -> None: for i, option in enumerate(options): if not isinstance(option, Option): if len(option) != 2: - raise ValueError('Option #{} does not consist of an ' - 'option and shortcut.'.format(i)) + raise ValueError(f'Option #{i} does not consist of an ' + 'option and shortcut.') options[i] = StandardOption(*option) # TODO: Test for uniquity @@ -628,7 +624,7 @@ def emit(self, record) -> None: self.UI.output(msg, targetStream=self.stream) -class MaxLevelFilter(): +class MaxLevelFilter: """Filter that only passes records at or below a specific level. diff --git a/pywikibot/userinterfaces/transliteration.py b/pywikibot/userinterfaces/transliteration.py index 4ad1d85606..7101aac8b4 100644 --- a/pywikibot/userinterfaces/transliteration.py +++ b/pywikibot/userinterfaces/transliteration.py @@ -1105,8 +1105,7 @@ class Transliterator: """Class to transliterating text.""" def __init__(self, encoding: str) -> None: - """ - Initialize the transliteration mapping. + """Initialize the transliteration mapping. :param encoding: the encoding available. Any transliterated character which can't be mapped, will be removed from the mapping. diff --git a/pywikibot/version.py b/pywikibot/version.py index 3d33a29438..4daf5a5108 100644 --- a/pywikibot/version.py +++ b/pywikibot/version.py @@ -304,8 +304,7 @@ def getversion_onlinerepo(path: str = 'branches/master') -> str: def get_module_filename(module) -> str | None: - """ - Retrieve filename from an imported pywikibot module. + """Retrieve filename from an imported pywikibot module. It uses the __file__ attribute of the module. If it's file extension ends with py and another character the last character is discarded when the py @@ -327,8 +326,7 @@ def get_module_filename(module) -> str | None: def get_module_mtime(module): - """ - Retrieve the modification time from an imported module. + """Retrieve the modification time from an imported module. :param module: The module instance. :type module: module @@ -411,9 +409,11 @@ def package_versions( path = _file info['path'] = path - assert path not in paths, \ - 'Path {} of the package {} is in defined paths as {}' \ - .format(path, name, paths[path]) + assert path not in paths, ( + f'Path {path} of the package {name} is in defined paths as ' + f'{paths[path]}' + ) + paths[path] = name if '__version__' in package.__dict__: diff --git a/pywikibot/xmlreader.py b/pywikibot/xmlreader.py index 2788ff741e..a1994cffbf 100644 --- a/pywikibot/xmlreader.py +++ b/pywikibot/xmlreader.py @@ -256,8 +256,7 @@ def _fetch_revs(self, elem: Element, with_id=False) -> Iterator[RawRev]: @staticmethod def parse_restrictions(restrictions: str) -> tuple[str | None, str | None]: - """ - Parse the characters within a restrictions tag. + """Parse the characters within a restrictions tag. Returns strings representing user groups allowed to edit and to move a page, where None means there are no restrictions. diff --git a/requirements.txt b/requirements.txt index 6c3d89de7f..4193d61c53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,6 +32,8 @@ wikitextparser>=0.47.5 # OAuth support # mwoauth 0.2.4 is needed because it supports getting identity information # about the user +# Due to T380270 PyJWT must be set +PyJWT != 2.10.0, != 2.10.1; python_version > '3.8' mwoauth>=0.2.4,!=0.3.1 # interwiki_graph.py module and category_graph.py script: diff --git a/scripts/CHANGELOG.rst b/scripts/CHANGELOG.rst index cf28d26c0c..e39cd84ccd 100644 --- a/scripts/CHANGELOG.rst +++ b/scripts/CHANGELOG.rst @@ -1,6 +1,35 @@ Scripts Changelog ================= +9.6.0 +----- + +* i18n updates + +create_isbn_edition +^^^^^^^^^^^^^^^^^^^ + +* Use :meth:`DataSite.search()<pywikibot.site._generators.GeneratorsMixin.search>` in ``get_item_with_prop_value`` +* Use :meth:`DataSite.search_entities()<pywikibot.site._datasite.DataSite.search_entities>` method in + ``get_item_list`` function +* Call :func:`pywikibot.handle_args` first to provide help message +* Enhance searching the item number from the ISBN number, code improvements (:phab:`T314942`) + +dataextend +^^^^^^^^^^ + +* The script is deprecated and will be removed from script package with Pywikibot 10. + +replace +^^^^^^^ + +* Strip newlines from pairsfile lines (:phab:`T378647`) + +weblinkchecker +^^^^^^^^^^^^^^ + +* Remove unused *error* parameter from ``History.log()`` method (:phab:`T380693`) + 9.5.0 ----- diff --git a/scripts/__init__.py b/scripts/__init__.py index 109f6044cf..96c14f7134 100644 --- a/scripts/__init__.py +++ b/scripts/__init__.py @@ -34,7 +34,7 @@ from pathlib import Path -__version__ = '9.5.0' +__version__ = '9.6.0' #: defines the entry point for pywikibot-scripts package base_dir = Path(__file__).parent diff --git a/scripts/add_text.py b/scripts/add_text.py index 216ca023ea..9e84fd1cfc 100755 --- a/scripts/add_text.py +++ b/scripts/add_text.py @@ -176,8 +176,7 @@ def treat_page(self) -> None: def main(*argv: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. @@ -199,8 +198,7 @@ def main(*argv: str) -> None: def parse(argv: Sequence[str], generator_factory: pagegenerators.GeneratorFactory ) -> dict[str, bool | str]: - """ - Parses our arguments and provide a dictionary with their values. + """Parses our arguments and provide a dictionary with their values. :param argv: input arguments to be parsed :param generator_factory: factory that will determine what pages to diff --git a/scripts/archivebot.py b/scripts/archivebot.py index 3a2b22085c..732b3623fc 100755 --- a/scripts/archivebot.py +++ b/scripts/archivebot.py @@ -287,8 +287,7 @@ def calc_md5_hexdigest(txt, salt) -> str: class DiscussionThread: - """ - An object representing a discussion thread on a page. + """An object representing a discussion thread on a page. It represents something that is of the form:: @@ -423,7 +422,7 @@ def load_page(self) -> None: header, threads, footer = extract_sections(text, self.site) header = header.replace(marker, '') if header and footer: - self.header = '\n\n'.join((header.rstrip(), footer, '')) + self.header = f'{header.rstrip()}\n\n{footer}\n\n' else: self.header = header + footer @@ -906,8 +905,7 @@ def show_md5_key(calc, salt, site) -> bool: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. @@ -1021,7 +1019,7 @@ def signal_handler(signum, frame): pywikibot.info(f'{canceled} done') break - elif not process_page(pg, *botargs): + if not process_page(pg, *botargs): break diff --git a/scripts/basic.py b/scripts/basic.py index 446554440f..5d9483f7c1 100755 --- a/scripts/basic.py +++ b/scripts/basic.py @@ -75,8 +75,7 @@ class BasicBot( AutomaticTWSummaryBot, # Automatically defines summary; needs summary_key ): - """ - An incomplete sample bot. + """An incomplete sample bot. :ivar summary_key: Edit summary message key. The message that should be used is placed on /i18n subdirectory. The file containing these @@ -129,8 +128,7 @@ def treat_page(self) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/blockpageschecker.py b/scripts/blockpageschecker.py index 02f5562aa1..0a2c6d9ae6 100755 --- a/scripts/blockpageschecker.py +++ b/scripts/blockpageschecker.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -A bot to remove stale protection templates from pages that are not protected. +"""A bot to remove stale protection templates from unprotected pages. Very often sysops block the pages for a set time but then they forget to remove the warning! This script is useful if you want to remove those @@ -438,8 +437,7 @@ def understand_block(): def main(*args: str) -> None: - """ - Process command line arguments and perform task. + """Process command line arguments and perform task. If args is an empty list, sys.argv is used. diff --git a/scripts/category.py b/scripts/category.py index 83ff20b4c1..12ac25e042 100755 --- a/scripts/category.py +++ b/scripts/category.py @@ -197,7 +197,7 @@ CFD_TEMPLATE_REGEX = re.compile(r'<!--\s*BEGIN CFD TEMPLATE\s*-->.*?' r'<!--\s*END CFD TEMPLATE\s*-->\n?', - flags=re.I | re.M | re.S) + flags=re.IGNORECASE | re.MULTILINE | re.DOTALL) cfd_templates = { 'wikipedia': { @@ -239,8 +239,7 @@ def determine_type_target( self, page: pywikibot.Page ) -> pywikibot.Page | None: - """ - Return page to be categorized by type. + """Return page to be categorized by type. :param page: Existing, missing or redirect page to be processed. :return: Page to be categorized. @@ -284,8 +283,7 @@ def determine_type_target( return None def determine_template_target(self, page) -> pywikibot.Page: - """ - Return template page to be categorized. + """Return template page to be categorized. Categories for templates can be included in <includeonly> section of template doc page. @@ -533,7 +531,7 @@ def treat(self, page) -> None: if self.includeonly == ['includeonly']: tagname = 'includeonly' tagnameregexp = re.compile(fr'(.*)(<\/{tagname}>)', - re.I | re.DOTALL) + re.IGNORECASE | re.DOTALL) categorytitle = catpl.title( as_link=True, allow_interwiki=False) if tagnameregexp.search(text): @@ -702,22 +700,20 @@ def __init__(self, oldcat, elif deletion_comment == self.DELETION_COMMENT_SAME_AS_EDIT_COMMENT: # Use the edit comment as the deletion comment. self.deletion_comment = self.comment + # Deletion comment is set to internationalized default. + elif self.newcat: + # Category is moved. + self.deletion_comment = i18n.twtranslate(self.site, + 'category-was-moved', + template_vars) else: - # Deletion comment is set to internationalized default. - if self.newcat: - # Category is moved. - self.deletion_comment = i18n.twtranslate(self.site, - 'category-was-moved', - template_vars) - else: - # Category is deleted. - self.deletion_comment = i18n.twtranslate( - self.site, 'category-was-disbanded') + # Category is deleted. + self.deletion_comment = i18n.twtranslate( + self.site, 'category-was-disbanded') self.move_comment = move_comment if move_comment else self.comment def run(self) -> None: - """ - The main bot function that does all the work. + """The main bot function that does all the work. For readability it is split into several helper functions: - _movecat() @@ -824,8 +820,7 @@ def _delete(self, moved_page, moved_talk) -> None: self.counter['delete talk'] += 1 def _change(self, gen) -> None: - """ - Private function to move category contents. + """Private function to move category contents. Do not use this function from outside the class. @@ -1052,8 +1047,8 @@ def run(self) -> None: class CategoryTidyRobot(Bot, CategoryPreprocess): - """ - Robot to move members of a category into sub- or super-categories. + + """Robot to move members of a category into sub- or super-categories. Specify the category title on the command line. The robot will pick up the page, look for all sub- and super-categories, and show @@ -1098,8 +1093,7 @@ def move_to_category(self, member: pywikibot.Page, original_cat: pywikibot.Category, current_cat: pywikibot.Category) -> None: - """ - Ask whether to move it to one of the sub- or super-categories. + """Ask whether to move it to one of the sub- or super-categories. Given a page in the original_cat category, ask the user whether to move it to one of original_cat's sub- or super-categories. @@ -1113,6 +1107,7 @@ def move_to_category(self, :param current_cat: a category which is questioned. """ class CatContextOption(ContextOption): + """An option to show more and more context and categories.""" @property @@ -1137,6 +1132,7 @@ def out(self) -> str: return text class CatIntegerOption(IntegerOption): + """An option allowing a range of integers.""" @staticmethod @@ -1499,8 +1495,7 @@ def treat(self, child) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/category_graph.py b/scripts/category_graph.py index fd46d3042a..77c6b012a6 100755 --- a/scripts/category_graph.py +++ b/scripts/category_graph.py @@ -67,6 +67,7 @@ class CategoryGraphBot(SingleSiteBot): + """Bot to create graph of the category structure.""" @staticmethod @@ -216,8 +217,7 @@ def run(self) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/category_redirect.py b/scripts/category_redirect.py index 6f1b1e510b..aadb8bfc5a 100755 --- a/scripts/category_redirect.py +++ b/scripts/category_redirect.py @@ -339,7 +339,7 @@ def setup_soft_redirect(self): template='|'.join(item.replace(' ', '[ _]+') for item in self.template_list), catns=self.site.namespace(14)), - re.I | re.X) + re.IGNORECASE | re.VERBOSE) nonemptypages = [] catpages = set() @@ -560,8 +560,7 @@ def teardown(self) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/change_pagelang.py b/scripts/change_pagelang.py index fc7ee8239f..587fce7ec1 100755 --- a/scripts/change_pagelang.py +++ b/scripts/change_pagelang.py @@ -124,8 +124,7 @@ def treat(self, page) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/checkimages.py b/scripts/checkimages.py index 6dd9bedccd..ca695d8571 100755 --- a/scripts/checkimages.py +++ b/scripts/checkimages.py @@ -781,8 +781,7 @@ def load_hidden_templates(self) -> None: def important_image( list_given: list[tuple[float, pywikibot.FilePage]] ) -> pywikibot.FilePage: - """ - Get tuples of image and time, return the most used or oldest image. + """Get tuples of image and time, return the most used or oldest image. .. versionchanged:: 7.2 itertools.zip_longest is used to stop `using_pages` as soon as @@ -1165,8 +1164,7 @@ def mini_template_check(self, template) -> bool: return False def template_in_list(self) -> None: - """ - Check if template is in list. + """Check if template is in list. The problem is the calls to the MediaWiki system because they can be pretty slow. While searching in a list of objects is really fast, so @@ -1184,8 +1182,7 @@ def template_in_list(self) -> None: break def smart_detection(self) -> tuple[str, bool]: - """ - Detect templates. + """Detect templates. The bot instead of checking if there's a simple template in the image's description, checks also if that template is a license or @@ -1265,22 +1262,21 @@ def smart_detection(self) -> tuple[str, bool]: else: pywikibot.info('Skipping the file...') self.some_problem = False - else: - if not self.seems_ok and self.license_found: - rep_text_license_fake = ((self.list_entry - + "seems to have a ''fake license''," - ' license detected:' - ' <nowiki>%s</nowiki>') % - (self.image_name, self.license_found)) - print_with_time_zone( - f'{self.image_name} seems to have a fake license: ' - f'{self.license_found}, reporting...') - self.report_image(self.image_name, - rep_text=rep_text_license_fake, - addings=False) - elif self.license_found: - pywikibot.info(f'[[{self.image_name}]] seems ok, license ' - f'found: {{{{{self.license_found}}}}}...') + elif not self.seems_ok and self.license_found: + rep_text_license_fake = ((self.list_entry + + "seems to have a ''fake license''," + ' license detected:' + ' <nowiki>%s</nowiki>') % + (self.image_name, self.license_found)) + print_with_time_zone( + f'{self.image_name} seems to have a fake license: ' + f'{self.license_found}, reporting...') + self.report_image(self.image_name, + rep_text=rep_text_license_fake, + addings=False) + elif self.license_found: + pywikibot.info(f'[[{self.image_name}]] seems ok, license ' + f'found: {{{{{self.license_found}}}}}...') return (self.license_found, self.white_templates_found) @staticmethod @@ -1325,8 +1321,7 @@ def skip_images(self, skip_number, limit) -> bool: @staticmethod def wait(generator, wait_time) -> Generator[pywikibot.FilePage]: - """ - Skip the images uploaded before x seconds. + """Skip the images uploaded before x seconds. Let the users to fix the image's problem alone in the first x seconds. """ @@ -1363,7 +1358,7 @@ def is_tagged(self) -> bool: if '{{' in i: regex_pattern = re.compile( r'\{\{(?:template)?%s ?(?:\||\r?\n|\}|<|/) ?' - % i.split('{{')[1].replace(' ', '[ _]'), re.I) + % i.split('{{')[1].replace(' ', '[ _]'), re.IGNORECASE) result = regex_pattern.findall(self.image_check_text) if result: return True @@ -1538,8 +1533,7 @@ def check_step(self) -> None: def main(*args: str) -> bool: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/claimit.py b/scripts/claimit.py index 3f1ba9e490..68724c9ba0 100755 --- a/scripts/claimit.py +++ b/scripts/claimit.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -A script that adds claims to Wikidata items based on a list of pages. +"""A script that adds claims to Wikidata items based on a list of pages. These command line parameters can be used to specify which pages to work on: @@ -99,8 +98,7 @@ def treat_page_and_item(self, page, item) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/commons_information.py b/scripts/commons_information.py index 7da3aa6763..794408a2bb 100755 --- a/scripts/commons_information.py +++ b/scripts/commons_information.py @@ -296,8 +296,7 @@ def treat_page(self) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/commonscat.py b/scripts/commonscat.py index b71a5597a5..9074deb8f1 100755 --- a/scripts/commonscat.py +++ b/scripts/commonscat.py @@ -280,8 +280,7 @@ def skipPage(page) -> bool: return False def treat_page(self) -> None: - """ - Add CommonsCat template to page. + """Add CommonsCat template to page. Take a page. Go to all the interwiki page looking for a commonscat template. When all the interwiki's links are checked and a proper @@ -493,7 +492,7 @@ def checkCommonscatLink(self, name: str = ''): r'(?P<newcat1>[^\|\}]+)(\|[^\}]+)?\]\]|' r'Robot: Changing Category:(.+) ' r'to Category:(?P<newcat2>.+)') - m = re.search(regex, logcomment, flags=re.I) + m = re.search(regex, logcomment, flags=re.IGNORECASE) if not m: pywikibot.info( @@ -531,8 +530,7 @@ def checkCommonscatLink(self, name: str = ''): def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/coordinate_import.py b/scripts/coordinate_import.py index c802a700f6..a0cdba8521 100755 --- a/scripts/coordinate_import.py +++ b/scripts/coordinate_import.py @@ -76,8 +76,7 @@ def __init__(self, **kwargs) -> None: self.create_missing_item = self.opt.create def has_coord_qualifier(self, claims) -> str | None: - """ - Check if self.prop is used as property for a qualifier. + """Check if self.prop is used as property for a qualifier. :param claims: the Wikibase claims to check in :type claims: dict @@ -91,8 +90,7 @@ def has_coord_qualifier(self, claims) -> str | None: return None def item_has_coordinates(self, item) -> bool: - """ - Check if the item has coordinates. + """Check if the item has coordinates. :return: whether the item has coordinates """ @@ -115,17 +113,16 @@ def treat_page_and_item(self, page, item) -> None: return if page is None: # running over items, search in linked pages - for page in item.iterlinks(): - if page.site.has_extension('GeoData') \ - and self.try_import_coordinates_from_page(page, item): + for p in item.iterlinks(): + if p.site.has_extension('GeoData') \ + and self.try_import_coordinates_from_page(p, item): break return self.try_import_coordinates_from_page(page, item) def try_import_coordinates_from_page(self, page, item) -> bool: - """ - Try import coordinate from the given page to the given item. + """Try import coordinate from the given page to the given item. :return: whether any coordinates were found and the import was successful @@ -142,7 +139,7 @@ def try_import_coordinates_from_page(self, page, item) -> bool: pywikibot.info( f'Adding {coordinate.lat}, {coordinate.lon} to {item.title()}') - # todo: handle exceptions using self.user_add_claim + # TODO: handle exceptions using self.user_add_claim try: item.addClaim(newclaim) except CoordinateGlobeUnknownError as e: @@ -153,8 +150,7 @@ def try_import_coordinates_from_page(self, page, item) -> bool: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/cosmetic_changes.py b/scripts/cosmetic_changes.py index a916aef7f7..346d721309 100755 --- a/scripts/cosmetic_changes.py +++ b/scripts/cosmetic_changes.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -This module can do slight modifications to tidy a wiki page's source code. +"""This module can do slight modifications to tidy a wiki page's source code. The changes are not supposed to change the look of the rendered wiki page. @@ -91,8 +90,7 @@ def treat_page(self) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/create_isbn_edition.py b/scripts/create_isbn_edition.py index 427b7e1894..a802484fe8 100755 --- a/scripts/create_isbn_edition.py +++ b/scripts/create_isbn_edition.py @@ -1,22 +1,25 @@ #!/usr/bin/env python3 -r"""Pywikibot script to load ISBN related data into Wikidata. +r"""Pywikibot client to load ISBN linked data into Wikidata. Pywikibot script to get ISBN data from a digital library, and create or amend the related Wikidata item for edition (with the -:samp:`P212={ISBN number}` as unique external ID). +:samp:`P212, {ISBN number}` as unique external ID). Use digital libraries to get ISBN data in JSON format, and integrate the results into Wikidata. +.. note:: + ISBN data should only be used for editions, and not for written works. + Then the resulting item number can be used e.g. to generate Wikipedia references using template ``Cite_Q``. -**Parameters**: +**Parameters:** All parameters are optional: .. code:: text - *P1:* digital library (default goob "-") + *P1:* digital library (default wiki "-") bnf Catalogue General (France) bol Bol.com @@ -27,64 +30,83 @@ mcues Ministerio de Cultura (Spain) openl OpenLibrary.org porbase urn.porbase.org Portugal - sbn Servizio Bibliotecario Nazionale + sbn Servizio Bibliotecario Nazionale (Italy) wiki wikipedia.org - worldcat WorldCat + worldcat WorldCat (wc) *P2:* ISO 639-1 language code. Default LANG; e.g. en, nl, fr, de, es, it, etc. *P3 P4...:* P/Q pairs to add additional claims (repeated) e.g. P921 Q107643461 (main subject: database management - linked to P2163 Fast ID) - - *stdin:* ISBN numbers (International standard book number) + linked to P2163, Fast ID 888037) - Free text (e.g. Wikipedia references list, or publication list) - is accepted. Identification is done via an ISBN regex expression. + *stdin:* List of ISBN numbers (International standard book + number, version 10 or 13). Free text (e.g. + Wikipedia references list, or publication list) is + accepted. Identification is done via an ISBN regex + expression. **Functionality:** - * The ISBN number is used as a primary key (P212 where no duplicates - are allowed. The item update is not performed when there is no - unique match - * Statements are added or merged incrementally; existing data is not - overwritten. - * Authors and publishers are searched to get their item number - (ambiguous items are skipped) - * Book title and subtitle are separated with '.', ':', or '-' - * This script can be run incrementally with the same parameters - Caveat: Take into account the Wikidata Query database replication - delay. Wait for minimum 5 minutes to avoid creating duplicate - objects. + * Both ISBN-10 and ISBN-13 numbers are accepted as input. + * Only ISBN-13 numbers are stored. ISBN-10 numbers are only used for + identification purposes; they are not stored. + * The ISBN number is used as a primary key; no two items can have + the same P212 ISBN number. The item update is not performed when + there is no unique match. Only editions are updated or created. + * Individual statements are added or merged incrementally; existing + data is not overwritten. + * Authors and publishers are searched to get their item number; + unknown of ambiguous items are skipped. + * Book title and subtitle are separated with either '.', ':', or '-' + in that order. + * Detect author, illustrator, writer preface, afterwork instances. + * Add profession "author" to individual authors. + * This script can be run incrementally. + +**Examples:** + Default library (Google Books), language (LANG), no additional + statements: + + pwb create_isbn_edition.py 9789042925564 + + Wikimedia, language English, main subject: database management: + + pwb create_isbn_edition.py wiki en P921 Q107643461 978-0-596-10089-6 **Data quality:** + * ISBN numbers (P212) are only assigned to editions. + * A written work should not have an ISBN number (P212). + * For targets of P629 *(edition of)* amend "is an Q47461344 + *(written work)* instance" and "inverse P747 *(work has edition)*" + statements * Use https://query.wikidata.org/querybuilder/ to identify P212 duplicates. Merge duplicate items before running the script again. - * The following properties should only be used for written works + * The following properties should only be used for written works, + not for editions: * P5331: OCLC work ID (editions should only have P243) * P8383: Goodreads-identificatiecode for work (editions should only have P2969) -**Examples:** - Default library (Google Books), language (LANG), no additional - statements: +**Return status:** + The following status codes are returned to the shell:: - pwb create_isbn_edition.py 9789042925564 + 3 Invalid or missing parameter + 4 Library not installed + 12 Item does not exist + 20 Network error - Wikimedia, language Dutch, main subject: database management: - - pwb create_isbn_edition.py wiki en P921 Q107643461 978-0-596-10089-6 - -**Standard ISBN properties:** +**Standard ISBN properties for editions:** :: - P31:Q3331189: instance of edition + P31:Q3331189: instance of edition (mandatory statement) P50: author P123: publisher - P212: canonical ISBN number (lookup via Wikidata Query) - P407: language of work - (Qnumber linked to ISO 639-1 language code) + P212: canonical ISBN number (with dashes; searchable + via Wikidata Query) + P407: language of work (Qnumber linked to ISO 639-1 + language code) P577: date of publication (year) P1476: book title P1680: subtitle @@ -92,65 +114,50 @@ **Other ISBN properties:** :: - P291: place of publication P921: main subject (inverse lookup from external Fast ID P2163) P629: work for edition P747: edition of work - P1104: number of pages **Qualifiers:** :: + P248: Source + P813: Retrieval date P1545: (author) sequence number **External identifiers:** :: - P213: ISNI ID P243: OCLC ID - P496: ORCID iD - P675: Google Books-identificatiecode P1036: Dewey Decimal Classification P2163: Fast ID (inverse lookup via Wikidata Query) -> P921: main subject + + (not implemented) P2969: Goodreads-identificatiecode (only for written works) P5331: OCLC work ID (editions should only have P243) + + (not implemented) P8383: Goodreads-identificatiecode for work (editions should only have P2969) + P213: ISNI ID + P496: ORCID ID + P675: Google Books-identificatiecode -**Author:** - Geert Van Pamel (User:Geertivp), 2022-08-04, +**Unavailable properties from digital library:** + :: - Licensed under MIT amd GNU General Public License v3.0 + (not implemented by isbnlib) + P98: Editor + P110: Illustrator/photographer + P291: place of publication + P1104: number of pages + ?: edition format (hardcover, paperback) -**Documentation:** - * :wiki:`ISBN` - * :wiki:`List_of_ISO_639-1_codes` - * https://www.geeksforgeeks.org/searching-books-with-python/ - * https://www.freecodecamp.org/news/python-json-how-to-convert-a-string-to-json/ - * https://pypi.org/project/isbnlib/ - * https://buildmedia.readthedocs.org/media/pdf/isbnlib/v3.4.5/isbnlib.pdf - * https://isbntools.readthedocs.io/en/latest/info.html - * https://www.wikidata.org/wiki/Property:P212 - * https://www.wikidata.org/wiki/Wikidata:WikiProject_Books - * WikiProject Books: https://www.wikidata.org/wiki/Q21831105 - * https://www.wikidata.org/wiki/Wikidata:List_of_properties/work - * https://www.wikidata.org/wiki/Template:Book_properties - * https://www.wikidata.org/wiki/Template:Bibliographic_properties - * http://classify.oclc.org/classify2/ClassifyDemo - * https://www.wikidata.org/wiki/Wikidata:WikiProject_Source_MetaData - * https://www.wikidata.org/wiki/Help:Sources - * https://www.wikidata.org/wiki/Q22696135 - * https://meta.wikimedia.org/wiki/Community_Wishlist_Survey_2021/Wikidata/Bibliographical_references/sources_for_wikidataitems - * https://doc.wikimedia.org/pywikibot/master/api_ref/pywikibot.html - * https://doc.wikimedia.org/pywikibot/master/ - * https://docs.python.org/3/howto/logging.html - * https://wikitech.wikimedia.org/wiki/Portal:Toolforge - * http://www.isbn.org/standards/home/isbn/international/hyphenation-instructions.asp - * https://www.wikidata.org/wiki/Wikidata:Pywikibot\_-_Python_3_Tutorial/Setting_qualifiers - * https://www.wikidata.org/wiki/Wikidata:Pywikibot\_-_Python_3_Tutorial/Setting_statements +**Author:** + Geert Van Pamel (User:Geertivp), MIT License, 2022-08-04, **Prerequisites:** In addition to Pywikibot the following ISBN lib package is mandatory; @@ -164,8 +171,8 @@ .. code:: shell - pip install isbnlib-bol pip install isbnlib-bnf + pip install isbnlib-bol pip install isbnlib-dnb pip install isbnlib-kb pip install isbnlib-loc @@ -173,20 +180,90 @@ **Restrictions:** * Better use the ISO 639-1 language code parameter as a default. The - language code is not always available from the digital library. - * SPARQL queries run on a replicated database. - - .. important:: - Replication delay: wait 5 minutes before retry, otherwise there - is a risk for creating duplicates. - -**Algorithm:** - #. Get parameters - #. Validate parameters - #. Get ISBN data - #. Convert ISBN data - #. Get additional data - #. Register ISBN data into Wikidata (create or amend items or claims) + language code is not always available from the digital library; + therefore we need a default. + * Publisher unknown: + * Missing P31:Q2085381 statement, missing subclass in script + * Missing alias + * Create publisher + * Unknown author: create author as a person + +**Known Problems:** + * Unknown ISBN, e.g. 9789400012820 + * If there is no ISBN data available for an edition either returns + no output (goob = Google Books), or an error message (wiki, openl). + The script is taking care of both. Try another library instance. + * Only 6 specific ISBN attributes are listed by the webservice(s), + missing are e.g.: place of publication, number of pages + * Some digital libraries have more registrations than others. + * Some digital libraries have data quality problems. + * Not all ISBN atttributes have data values (authors, publisher, + date of publication), language can be missing at the digital + library. + * How to add still more digital libraries? + + * This would require an additional isbnlib module + * Does the KBR has a public ISBN service (Koninklijke Bibliotheek + van België)? + * The script uses multiple webservice calls; script might take time, + but it is automated. + * Need to manually amend ISBN items that have no author, publisher, + or other required data + * You could use another digital library + * Which other services to use? + * BibTex service is currently unavailable + * Filter for work properties: https://www.wikidata.org/wiki/Q63413107 + + :: + + ['9781282557246', '9786612557248', '9781847196057', '9781847196040'] + P5331: OCLC identification code for work 793965595; should only + have P243) + P8383: Goodreads identification code for work 13957943; should + only have P2969) + * ERROR: an HTTP error has ocurred e.g. (503) Service Unavailable + * error: externally-managed-environment + + ``isbnlib-kb`` cannot be installed via :code:`pip install` command. + It raises ``error: externally-managed-environment`` because this + environment is externally managed. + + To install Python packages system-wide, try :samp:`apt install + python3-{xyz}`, where *xyz* is the package you are trying to + install. + + If you wish to install a non-Debian-packaged Python package, + create a virtual environment using + :code:`python3 -m venv path/to/venv`. Then use + :code:`path/to/venv/bin/python` and :code:`path/to/venv/bin/pip`. + Make sure you have ``python3-full`` installed. + + If you wish to install a non-Debian packaged Python application, + it may be easiest to use :samp:`pipx install {xyz}`, which will + manage a virtual environment for you. Make sure you have ``pipx`` + installed. + + .. seealso:: See :pylib:`venv` for more information about virtual + environments. + .. note:: If you believe this is a mistake, please contact your + Python installation or OS distribution provider. You can + override this, at the risk of breaking your Python installation + or OS, by passing ``--break-system-packages`` to ``pip``. + .. hint:: See :pep:`668` for the detailed specification. + + You need to install a local python environment: + + - https://pip.pypa.io/warnings/venv + - :python:`tutorial/venv` + + .. code-block:: bash + + sudo -s + apt install python3-full + python3 -m venv /opt/python + /opt/python/bin/pip install pywikibot + /opt/python/bin/pip install isbnlib-kb + /opt/python/bin/python ../userscripts/create_isbn_edition.py kb **Environment:** The python script can run on the following platforms: @@ -196,47 +273,93 @@ * Toolforge Portal * PAWS - LANG: ISO 639-1 language code + LANG: default ISO 639-1 language code **Applications:** - Generate a book reference. Example for (wp.en): + Generate a book reference. Example for wp.en only: .. code:: wikitext {{Cite Q|Q63413107}} + Use the Visual editor reference with Qnumber. + .. seealso:: + - https://www.wikidata.org/wiki/Wikidata:WikiProject_Books + - https://www.wikidata.org/wiki/Q21831105 (WikiProject Books) - https://meta.wikimedia.org/wiki/WikiCite + - https://phabricator.wikimedia.org/tag/wikicite/ - https://www.wikidata.org/wiki/Q21831105 (WikiCite) - https://www.wikidata.org/wiki/Q22321052 (Cite_Q) - https://www.mediawiki.org/wiki/Global_templates - https://www.wikidata.org/wiki/Wikidata:WikiProject_Source_MetaData - - https://phabricator.wikimedia.org/tag/wikicite/ - https://meta.wikimedia.org/wiki/WikiCite/Shared_Citations + - https://www.wikidata.org/wiki/Q36524 (Authority control) + - https://meta.wikimedia.org/wiki/Community_Wishlist_Survey_2021/Wikidata/Bibliographical_references/sources_for_wikidataitems **Wikidata Query:** * List of editions about musicians: https://w.wiki/5aaz * List of editions having ISBN number: https://w.wiki/5akq **Related projects:** - * :phab:`T314942` (this script) + * :phab:`T314942` * :phab:`T282719` * :phab:`T214802` * :phab:`T208134` * :phab:`T138911` * :phab:`T20814` * :wiki:`User:Citation_bot` - * https://meta.wikimedia.org/wiki/Community_Wishlist_Survey_2021/Wikidata/Bibliographical_references/sources_for_wikidataitems * https://zenodo.org/record/55004#.YvwO4hTP1D8 **Other systems:** - * wiki:`bibliographic_database` + * https://isbn.org/ISBN_converter + * :wiki:`bibliographic_database` * https://www.titelbank.nl/pls/ttb/f?p=103:4012:::NO::P4012_TTEL_ID:3496019&cs=19BB8084860E3314502A1F777F875FE61 + * https://isbndb.com/apidocs/v2 + * https://isbndb.com/book/9780404150006 + +**Documentation:** + * :wiki:`ISBN` + * https://pypi.org/project/isbnlib/ + * https://buildmedia.readthedocs.org/media/pdf/isbnlib/v3.4.5/isbnlib.pdf + * https://www.wikidata.org/wiki/Property:P212 + * http://www.isbn.org/standards/home/isbn/international/hyphenation-instructions.asp + * https://isbntools.readthedocs.io/en/latest/info.html + * :wiki:`List_of_ISO_639-1_codes` + + * https://www.wikidata.org/wiki/Wikidata:List_of_properties/work + * https://www.wikidata.org/wiki/Template:Book_properties + * https://www.wikidata.org/wiki/Template:Bibliographic_properties + * https://www.wikidata.org/wiki/Wikidata:WikiProject_Source_MetaData + * https://www.wikidata.org/wiki/Help:Sources + * https://www.wikidata.org/wiki/Q22696135 (Wikidata references module) + + * https://www.geeksforgeeks.org/searching-books-with-python/ + * http://classify.oclc.org/classify2/ClassifyDemo + * :mod:`pywikibot` + * :api:`Search` + * https://www.mediawiki.org/wiki/Wikibase/API + + * :wiki:`Wikipedia:Book_sources` + * :wiki:`https://en.wikipedia.org/wiki/Wikipedia:ISBN` + * https://www.boek.nl/nur + * https://isbnlib.readthedocs.io/_/downloads/en/latest/pdf/ + * https://www.wikidata.org/wiki/Special:BookSources/978-94-014-9746-6 + + * **Goodreads:** + + - https://github.com/akkana/scripts/blob/master/bookfind.py + - https://www.kaggle.com/code/hoshi7/goodreads-analysis-and-recommending-books?scriptVersionId=18346227 + - https://help.goodreads.com/s/question/0D51H00005FzcX1SAJ/how-can-i-search-by-isbn + - https://help.goodreads.com/s/article/Librarian-Manual-ISBN-10-ISBN-13-and-ASINS + - https://www.goodreads.com/book/show/203964185-de-nieuwe-wereldeconomie .. versionadded:: 7.7 -""" # noqa: E501, W505, W605 +.. versionchanged:: 9.6 + several implementation improvements +""" # noqa: E501, W505 # # (C) Pywikibot team, 2022-2024 # @@ -246,14 +369,15 @@ import os # Operating system import re # Regular expressions (very handy!) -from itertools import islice +import sys # System calls +from contextlib import suppress +from datetime import date from pprint import pformat from typing import Any import pywikibot # API interface to Wikidata -from pywikibot import pagegenerators as pg # Wikidata Query interface from pywikibot.config import verbose_output as verbose -from pywikibot.data import api +from pywikibot.tools import first_upper try: @@ -266,258 +390,772 @@ except ImportError as e: unidecode = e -# Initialisation -booklib = 'goob' # Default digital library +# Global variables +# Module name (using the Pywikibot package) +pgmlic = 'MIT License' +creator = 'User:Geertivp' + +MAINLANG = 'en:mul' +MULANG = 'mul' + +# Exit on fatal error (can be disabled with -p; please take care) +exitfatal = True +exitstat = 0 # (default) Exit status + +# Wikibase properties +INSTANCEPROP = 'P31' +AUTHORPROP = 'P50' +EDITORPROP = 'P98' +PROFESSIONPROP = 'P106' +ILLUSTRATORPROP = 'P110' +PUBLISHERPROP = 'P123' +# STYLEPROP = 'P135' +# GENREPROP = 'P136' +# BASEDONPROP = 'P144' +# PREVSERIALPROP = 'P155' +# NEXTSERIALPROP = 'P156' +# PRIZEPROP = 'P166' +# SERIALPROP = 'P179' +# COLLIONPROP = 'P195' +ISBNPROP = 'P212' +ISNIIDPROP = 'P213' +# BNFIDPROP = 'P268' +PLACEPUBPROP = 'P291' +OCLDIDPROP = 'P243' +REFPROP = 'P248' +# EDITIONIDPROP = 'P393' +EDITIONLANGPROP = 'P407' +WIKILANGPROP = 'P424' +# ORIGCOUNTRYPROP = 'P495' +ORCIDIDPROP = 'P496' +PUBYEARPROP = 'P577' +WRITTENWORKPROP = 'P629' +# OPENLIBIDPROP = 'P648' +# TRANSLATORPROP = 'P655' +# PERSONPROP = 'P674' +GOOGLEBOOKIDPROP = 'P675' +# INTARCHIDPROP = 'P724' +EDITIONPROP = 'P747' +# CONTRIBUTORPROP = 'P767' +REFDATEPROP = 'P813' +# STORYLOCPROP = 'P840' +# PRINTEDBYPROP = 'P872' +MAINSUBPROP = 'P921' +# INSPIREDBYPROP = 'P941' +ISBN10PROP = 'P957' +# SUDOCIDPROP = 'P1025' +DEWCLASIDPROP = 'P1036' +# EULIDPROP = 'P1084' +# LIBTHINGIDPROP = 'P1085' +NUMBEROFPAGESPROP = 'P1104' +# LCOCLCCNIDPROP = 'P1144' +# LIBCONGRESSIDPROP = 'P1149' +# BNIDPROP = 'P1143' +# UDCPROP = 'P1190' +# DNBIDPROP = 'P1292' +DESCRIBEDBYPROP = 'P1343' +EDITIONTITLEPROP = 'P1476' +SEQNRPROP = 'P1545' +EDITIONSUBTITLEPROP = 'P1680' +# ASSUMEDAUTHORPROP = 'P1779' +# RSLBOOKIDPROP = 'P1815' +# RSLEDIDPROP = 'P1973' +# GUTENBERGIDPROP = 'P2034' +FASTIDPROP = 'P2163' +# NUMPARTSPROP = 'P2635' +PREFACEBYPROP = 'P2679' +AFTERWORDBYPROP = 'P2680' +GOODREADSIDPROP = 'P2969' +# CZLIBIDPROP = 'P3184' +# BABELIOIDPROP = 'P3631' +# ESTCIDPROP = 'P3939' +OCLCWORKIDPROP = 'P5331' +# K10IDPROP = 'P6721' +# CREATIVEWORKTYPE = 'P7937' +LIBCONGEDPROP = 'P8360' +GOODREADSWORKIDPROP = 'P8383' + +# Instances +AUTHORINSTANCE = 'Q482980' +ILLUSTRATORINSTANCE = 'Q15296811' +WRITERINSTANCE = 'Q36180' + +authorprop_list = { + AUTHORPROP, + EDITORPROP, + ILLUSTRATORPROP, + PREFACEBYPROP, + AFTERWORDBYPROP, +} + +# Profession author instances +author_profession = { + AUTHORINSTANCE, + ILLUSTRATORINSTANCE, + WRITERINSTANCE, +} + +# List of digital library synonyms +bookliblist = { + '-': 'wiki', + 'dnl': 'dnb', + 'google': 'goob', + 'gb': 'goob', + 'isbn': 'isbndb', + 'kbn': 'kb', + 'wc': 'worldcat', + 'wcat': 'worldcat', + 'wikipedia': 'wiki', + 'wp': 'wiki', +} + +# List of of digital libraries +# You can better run the script repeatedly with difference library sources. +# Content and completeness differs amongst libraryies. +bib_source = { + # database ID: item number, label, default language, package + 'bnf': ('Q193563', 'Catalogue General (France)', 'fr', 'isbnlib-bnf'), + 'bol': ('Q609913', 'Bol.Com', 'en', 'isbnlib-bol'), + 'dnb': ('Q27302', 'Deutsche National Library', 'de', 'isbnlib-dnb'), + 'goob': ('Q206033', 'Google Books', 'en', 'isbnlib'), # lib + # A (paying) api key is needed + 'isbndb': ('Q117793433', 'isbndb.com', 'en', 'isbnlib'), + 'kb': ('Q1526131', 'Koninklijke Bibliotheek (Nederland)', 'nl', + 'isbnlib-kb'), + # Not implemented in Belgium + # 'kbr': ('Q383931', 'Koninklijke Bibliotheek (België)', 'nl', 'isbnlib'), + 'loc': ('Q131454', 'Library of Congress (US)', 'en', 'isbnlib-loc'), + 'mcues': ('Q750403', 'Ministerio de Cultura (Spain)', 'es', + 'isbnlib-mcues'), + 'openl': ('Q1201876', 'OpenLibrary.org', 'en', 'isbnlib'), # lib + 'porbase': ('Q51882885', 'Portugal (urn.porbase.org)', 'pt', + 'isbnlib-porbase'), + 'sbn': ('Q576951', 'Servizio Bibliotecario Nazionale (Italië)', 'it', + 'isbnlib-sbn'), + 'wiki': ('Q121093616', 'Wikipedia.org', 'en', 'isbnlib'), # lib + 'worldcat': ('Q76630151', 'WorldCat (worldcat2)', 'en', + 'isbnlib-worldcat2'), + # isbnlib-oclc + # https://github.com/swissbib + # others to be added +} + +# Remap obsolete or non-standard language codes +langcode = { + 'dut': 'nl', + 'eng': 'en', + 'frans': 'fr', + 'fre': 'fr', + 'iw': 'he', + 'nld': 'nl', +} + +# Statement property target validation rules +propreqobjectprop = { + # Main subject statement requires an object with FAST ID property + MAINSUBPROP: {FASTIDPROP}, +} # ISBN number: 10 or 13 digits with optional dashes (-) -ISBNRE = re.compile(r'[0-9-]{10,17}') +# or DOI number with 10-prefix +ISBNRE = re.compile(r'[0-9–-]{10,17}') +NAMEREVRE = re.compile(r',(\s*.*)*$') # Reverse lastname, firstname PROPRE = re.compile(r'P[0-9]+') # Wikidata P-number QSUFFRE = re.compile(r'Q[0-9]+') # Wikidata Q-number +# Remove trailing () suffix (keep only the base label) +SUFFRE = re.compile(r'\s*[(].*[)]$') -# Other statements are added via command line parameters +# Required statement for edition +# Additional statements can be added via command line parameters target = { - 'P31': 'Q3331189', # Is an instance of an edition + INSTANCEPROP: 'Q3331189', # Is an instance of an edition + # other statements to add } -# Statement property and instance validation rules +# Instance validation rules for properties propreqinst = { - 'P50': 'Q5', # Author requires human - # Publisher requires publisher - 'P123': {'Q2085381', 'Q1114515', 'Q1320047'}, + AUTHORPROP: {'Q5'}, # Author requires human # Edition language requires at least one of (living, natural) language - 'P407': {'Q34770', 'Q33742', 'Q1288568'}, + EDITIONLANGPROP: {'Q34770', 'Q33742', 'Q1288568'}, + # Is an instance of an edition + INSTANCEPROP: {'Q24017414'}, + # Publisher requires type of publisher + PUBLISHERPROP: {'Q41298', 'Q479716', 'Q1114515', 'Q1320047', 'Q2085381'}, + # Written work (requires list) + WRITTENWORKPROP: ['Q47461344', 'Q7725634'], } -mainlang = os.getenv('LANG', 'en')[:2] # Default description language +# Wikidata transaction comment +transcmt = '#pwb Create ISBN edition' -# Connect to database -transcmt = '#pwb Create ISBN edition' # Wikidata transaction comment +def fatal_error(errcode, errtext): + """A fatal error has occurred. -def is_in_list(statement_list, checklist: list[str]) -> bool: - """Verify if statement list contains at least one item from the checklist. + Print the error message, and exit with an error code. + """ + global exitstat + + exitstat = max(exitstat, errcode) + pywikibot.critical(errtext) + if exitfatal: # unless we ignore fatal errors + sys.exit(exitstat) + else: + pywikibot.warning('Proceed after fatal error') + + +def get_item_header(header: str | list[str]) -> str: + """Get the item header (label, description, alias in user language). + + :param header: item label, description, or alias language list + :return: label, description, or alias in the first available language + """ + # Return one of the preferred labels + for lang in main_languages: + if lang in header: + return header[lang] + + # Return any other available label + for lang in header: + return header[lang] + + return '-' + + +def get_item_header_lang(header: str | list[str], lang: str) -> str: + """Get the item header (label, description, alias in user language). + + :param header: item label, description, or alias language list + :param lang: language code + :return: label, description, or alias in the first available language + """ + # Try to get any explicit language code + if lang in header: + return header[lang] + + return get_item_header(header) + + +def get_item_page(qnumber) -> pywikibot.ItemPage: + """Get the item; handle redirects.""" + if isinstance(qnumber, str): + item = pywikibot.ItemPage(repo, qnumber) + try: + item.get() + except pywikibot.exceptions.IsRedirectPageError: + # Resolve a single redirect error + item = item.getRedirectTarget() + label = get_item_header(item.labels) + pywikibot.warning( + f'Item {label} ({qnumber}) redirects to {item.getID()}') + qnumber = item.getID() + else: + item = qnumber + qnumber = item.getID() + + while item.isRedirectPage(): + # Should fix the sitelinks + item = item.getRedirectTarget() + label = get_item_header(item.labels) + pywikibot.warning( + f'Item {label} ({qnumber}) redirects to {item.getID()}') + qnumber = item.getID() + + return item + + +def get_language_preferences() -> list[str]: + """Get the list of preferred languages. + + Uses environment variables LANG, LC_ALL, and LANGUAGE, 'en' is + always appended. + + .. seealso:: + - :wiki:`List_of_ISO_639-1_codes - :param statement_list: Statement list - :param checklist: List of values - :Returns: True when match + :Return: List of ISO 639-1 language codes with strings delimited by + ':'. """ - return any(seq.getTarget().getID() in checklist for seq in statement_list) + # See also: + # https://www.gnu.org/software/gettext/manual/html_node/Locale-Environment-Variables.html + mainlang = os.getenv('LANGUAGE', + os.getenv('LC_ALL', + os.getenv('LANG', MAINLANG))).split(':') + main_languages = [lang.split('_')[0] for lang in mainlang] + # Cleanup language list (remove non-ISO codes) + for lang in main_languages: + if len(lang) > 3: + main_languages.remove(lang) -def get_item_list(item_name: str, instance_id): + for lang in MAINLANG.split(':'): + if lang not in main_languages: + main_languages.append(lang) + + return main_languages + + +def item_is_in_list(statement_list: list, itemlist: list[str]) -> bool: + """Verify if statement list contains at least one item from the itemlist. + + param statement_list: Statement list + param itemlist: List of values (string) + return: Whether the item matches + """ + for seq in statement_list: + with suppress(AttributeError): # Ignore NoneType error + isinlist = seq.getTarget().getID() + if isinlist in itemlist: + return True + return False + + +def item_has_label(item, label: str) -> bool: + """Verify if the item has a label. + + :param item: Item + :param label: Item label + :return: Whether the item has a label + """ + label = unidecode(label).casefold() + for lang in item.labels: + if unidecode(item.labels[lang]).casefold() == label: + return True + + for lang in item.aliases: + for seq in item.aliases[lang]: + if unidecode(seq).casefold() == label: + return True + + return False + + +def is_in_value_list(statement_list: list, valuelist: list[str]) -> bool: + """Verify if statement list contains at least one value from the valuelist. + + :param statement_list: Statement list of values + :param valuelist: List of values + :return: True when match, False otherwise + """ + for seq in statement_list: + if seq.getTarget() in valuelist: + return True + return False + + +def get_canon_name(baselabel: str) -> str: + """Get standardised name. + + :param baselabel: input label + """ + suffix = SUFFRE.search(baselabel) # Remove () suffix, if any + if suffix: + baselabel = baselabel[:suffix.start()] # Get canonical form + + colonloc = baselabel.find(':') + commaloc = NAMEREVRE.search(baselabel) + + # Reorder "lastname, firstname" and concatenate with space + if colonloc < 0 and commaloc: + baselabel = (baselabel[commaloc.start() + 1:] + + ' ' + baselabel[:commaloc.start()]) + baselabel = baselabel.replace(',', ' ') # Remove remaining "," + + # Remove redundant spaces + baselabel = ' '.join(baselabel.split()) + return baselabel + + +def get_item_list(item_name: str, + instance_id: str | set[str] | list[str]) -> set[str]: """Get list of items by name, belonging to an instance (list). + Normally there should have one single best match. The caller should + take care of homonyms. + + .. seealso:: + https://www.wikidata.org/w/api.php?action=help&modules=wbsearchentities + :param item_name: Item name (case sensitive) - :param instance_id: Instance ID (string, set, or list) - :Returns: Set of items (Q-numbers) + :param instance_id: Instance ID + :return: Set of items """ - item_list = set() # Empty set - params = { - 'action': 'wbsearchentities', - 'format': 'json', - 'type': 'item', - 'strictlanguage': False, - # All languages are searched, but labels are in native language - 'language': mainlang, - 'search': item_name, # Get item list from label - } - request = api.Request(site=repo, parameters=params) - result = request.submit() - - if 'search' in result: - for res in result['search']: - item = pywikibot.ItemPage(repo, res['id']) - item.get(get_redirect=True) - if 'P31' in item.claims: - for seq in item.claims['P31']: # Loop through instances - # Matching instance - if seq.getTarget().getID() in instance_id: - for lang in item.labels: # Search all languages - # Ignore label case and accents - if (unidecode(item_name.lower()) - == unidecode(item.labels[lang].lower())): - item_list.add(item.getID()) # Label math - for lang in item.aliases: - # Case sensitive for aliases - if item_name in item.aliases[lang]: - item_list.add(item.getID()) # Alias match + # Ignore accents and case + item_name_canon = unidecode(item_name).casefold() + + item_list = set() + # Loop though items, total should be reasonable value + for res in repo.search_entities(item_name, mainlang, total=20): + item = get_item_page(res['id']) + + # Matching instance + if INSTANCEPROP not in item.claims \ + or not item_is_in_list(item.claims[INSTANCEPROP], instance_id): + continue + + # Search all languages, ignore label case and accents + for lang in item.labels: + if item_name_canon == unidecode(item.labels[lang].casefold()): + item_list.add(item.getID()) # Label math + break + + for lang in item.aliases: + for seq in item.aliases[lang]: + if item_name_canon == unidecode(seq).casefold(): + item_list.add(item) # Alias match + break + + pywikibot.log(item_list) return item_list -def amend_isbn_edition(isbn_number: str) -> None: - """Amend ISBN registration. +def get_item_with_prop_value(prop: str, propval: str) -> set[str]: + """Get list of items that have a property/value statement. - Amend Wikidata, by registering the ISBN-13 data via P212, - depending on the data obtained from the digital library. + .. seealso:: :meth:`Site.search() + <pywikibot.site._generators.GeneratorsMixin.search>` - :param isbn_number: ISBN number (10 or 13 digits with optional hyphens) + :param prop: Property ID + :param propval: Property value + :return: List of items (Q-numbers) + """ + srsearch = f'{prop}:{propval}' + pywikibot.debug(f'Search statement: {srsearch}') + item_name_canon = unidecode(propval).casefold() + item_list = set() + + # Loop though items + for row in repo.search(srsearch, where='text', total=50): + qnumber = row['title'] + item = get_item_page(qnumber) + + if prop not in item.claims: + continue + + for seq in item.claims[prop]: + if unidecode(seq.getTarget()).casefold() == item_name_canon: + item_list.add(item) # Found match + break + + pywikibot.log(item_list) + return item_list + + +def amend_isbn_edition(isbn_number: str) -> int: + """Amend ISBN registration in Wikidata. + + It is registering the ISBN-13 data via P212, depending on the data + obtained from the digital library. + + :param isbn_number: ISBN number (10 or 13 digits with optional + hyphens) + :return: Return status which is: + + * 0: Amended (found or created) + * 1: Not found + * 2: Ambiguous + * 3: Other error """ isbn_number = isbn_number.strip() if not isbn_number: - return # Do nothing when the ISBN number is missing + return 3 # Do nothing when the ISBN number is missing + + pywikibot.info() + # Some digital library services raise failure try: + # Get ISBN basic data isbn_data = isbnlib.meta(isbn_number, service=booklib) - # {'ISBN-13': '9789042925564', - # 'Title': 'De Leuvense Vaart - Van De Vaartkom Tot Wijgmaal. ' - # 'Aspecten Uit De Industriele Geschiedenis Van Leuven', - # 'Authors': ['A. Cresens'], - # 'Publisher': 'Peeters Pub & Booksellers', - # 'Year': '2012', - # 'Language': 'nl'} + # { + # 'ISBN-13': '9789042925564', + # 'Title': 'De Leuvense Vaart - Van De Vaartkom Tot Wijgmaal. ' + # 'Aspecten Uit De Industriele Geschiedenis Van Leuven', + # 'Authors': ['A. Cresens'], + # 'Publisher': 'Peeters Pub & Booksellers', + # 'Year': '2012', + # 'Language': 'nl', + # } + except isbnlib._exceptions.NotRecognizedServiceError as error: + fatal_error(4, f'{error}\n pip install isbnlib-xxx') + except isbnlib._exceptions.NotValidISBNError as error: + pywikibot.error(error) + return 1 except Exception as error: # When the book is unknown the function returns - pywikibot.error(error) - return + pywikibot.error(f'{isbn_number} not found\n{error}') + return 1 - if len(isbn_data) < 6: - pywikibot.error(f'Unknown or incomplete digital library registration ' - f'for {isbn_number}') - return + # Others return an empty result + if not isbn_data: + pywikibot.error( + f'Unknown ISBN book number {isbnlib.mask(isbn_number)}') + return 1 # Show the raw results + # Can be very useful in troubleshooting if verbose: pywikibot.info('\n' + pformat(isbn_data)) - add_claims(isbn_data) + return add_claims(isbn_data) -def add_claims(isbn_data: dict[str, Any]) -> None: # noqa: C901 +def add_claims(isbn_data: dict[str, Any]) -> int: # noqa: C901 """Inspect isbn_data and add claims if possible.""" - # Get the book language from the ISBN book reference - booklang = mainlang # Default language - if isbn_data['Language']: - booklang = isbn_data['Language'].strip() - if booklang == 'iw': # Obsolete codes - booklang = 'he' - lang_list = list(get_item_list(booklang, propreqinst['P407'])) - - if not lang_list: - pywikibot.warning('Unknown language ' + booklang) - return - - if len(lang_list) != 1: - pywikibot.warning('Ambiguous language ' + booklang) - return + # targetx is not global (to allow for language specific editions) - target['P407'] = lang_list[0] + # Set default language from book library + # Mainlang was set to default digital library language code + booklang = mainlang + if isbn_data['Language']: + # Get the book language from the ISBN book number + # Can overwrite the default language + booklang = isbn_data['Language'].strip().lower() + + # Replace obsolete or non-standard codes + if booklang in langcode: + booklang = langcode[booklang] + + # Get Wikidata language code + lang_list = get_item_list(booklang, propreqinst[EDITIONLANGPROP]) + + # Hardcoded parameter + lang_list -= {'Q3504110'} # Remove duplicate "En" language + + if not lang_list: + # Can' t store unknown language (need to update mapping table...) + pywikibot.error(f'Unknown language {booklang}'.format(booklang)) + return 3 + + if len(lang_list) != 1: + # Ambiguous language + pywikibot.warning(f'Ambiguous language {booklang}\n' + f'[lang_item.getID() for lang_item in lang_list]') + return 3 + + # Set edition language item number + lang_item = lang_list.pop() + target[EDITIONLANGPROP] = lang_item.getID() + + # Require short Wikipedia language code + if len(booklang) > 3: + # Get official language code + if WIKILANGPROP in lang_item.claims: + booklang = lang_item.claims[WIKILANGPROP][0].getTarget() + + # Get edition title + edition_title = isbn_data['Title'].strip() + + # Split (sub)title with first matching delimiter + # By priority of parsing strings: + for seq in ['|', '. ', ' - ', ': ', '; ', ', ']: + titles = edition_title.split(seq) + if len(titles) > 1: + break + + if verbose: # Print (sub)title(s) + pywikibot.info('\n' + pformat(titles)) # Get formatted ISBN number isbn_number = isbn_data['ISBN-13'] # Numeric format isbn_fmtd = isbnlib.mask(isbn_number) # Canonical format pywikibot.info(isbn_fmtd) # First one - # Get (sub)title when there is a dot - titles = isbn_data['Title'].split('. ') # goob is using a '.' - if len(titles) == 1: - titles = isbn_data['Title'].split(': ') # Extract subtitle - if len(titles) == 1: - titles = isbn_data['Title'].split(' - ') # Extract subtitle + # Get main title and subtitle objectname = titles[0].strip() - subtitle = '' + subtitle = '' # If there was no delimiter, there is no subtitle if len(titles) > 1: - subtitle = titles[1].strip() - - # pywikibot.info book titles - pywikibot.debug(objectname) - pywikibot.debug(subtitle) # Optional - - # print subsequent subtitles, when available - for title in islice(titles, 2, None): - # Not stored in Wikidata... - pywikibot.debug(title.strip()) - - # Search the ISBN number in Wikidata both canonical and numeric - # P212 should have canonical hyphenated format - isbn_query = f"""# Get ISBN number -SELECT ?item WHERE {{ - VALUES ?isbn_number {{ - "{isbn_fmtd}" - "{isbn_number}" - }} - ?item wdt:P212 ?isbn_number. -}} -""" - - pywikibot.info(isbn_query) - generator = pg.WikidataSPARQLPageGenerator(isbn_query, site=repo) - - # Main loop for all DISTINCT items - rescnt = 0 - for rescnt, item in enumerate(generator, start=1): - qnumber = item.getID() - pywikibot.warning(f'Found item: {qnumber}') + # 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)) + + # Get addional data from the digital library + # This could fail with + # ISBNLibHTTPError('403 Are you making many requests?') + # Handle ISBN classification + # pwb create_isbn_edition - de P407 Q188 978-3-8376-5645-9 Q113460204 + # { + # 'owi': '11103651812', + # 'oclc': '1260160983', + # 'lcc': 'TK5105.8882', + # 'ddc': '300', + # 'fast': { + # '1175035': 'Wikis (Computer science)', + # '1795979': 'Wikipedia', + # '1122877': 'Social sciences' + # } + # } + isbn_classify = {} + try: + isbn_classify = isbnlib.classify(isbn_number) + except Exception as error: + pywikibot.error(f'Classify error, {error}') + else: + pywikibot.info('\n' + pformat(isbn_classify)) + + # Note that only older works have an ISBN10 number + isbn10_number = '' + isbn10_fmtd = '' + + # Take care of ISBNLibHTTPError + # (classify is more important than obsolete ISBN-10) + # ISBNs were not used before 1966 + # Since 2007, new ISBNs are only issued in the ISBN-13 format + if isbn_fmtd.startswith('978-'): + try: + # Returns empty string for non-978 numbers + isbn10_number = isbnlib.to_isbn10(isbn_number) + if isbn10_number: + isbn10_fmtd = isbnlib.mask(isbn10_number) + pywikibot.info(f'ISBN 10: {isbn10_fmtd}') + qnumber_list.update( + get_item_with_prop_value(ISBN10PROP, isbn10_fmtd)) + qnumber_list.update( + get_item_with_prop_value(ISBN10PROP, isbn10_number)) + except Exception as error: + pywikibot.error(f'ISBN 10 error, {error}') # Create or amend the item - if rescnt == 1: - item.get(get_redirect=True) # Update item - elif not rescnt: - label = {booklang: objectname} + + if not qnumber_list: + # Create the edition + label = {MULANG: objectname} item = pywikibot.ItemPage(repo) # Create item - item.editEntity({'labels': label}, summary=transcmt) + item.editLabels(label, summary=transcmt, bot=wdbotflag) + qnumber = item.getID() # Get new item number + status = 'Created' + elif len(qnumber_list) == 1: + item = qnumber_list.pop() qnumber = item.getID() - pywikibot.warning(f'Creating item: {qnumber}') + + # Update item only if edition, or instance is missing + if (INSTANCEPROP in item.claims + and not item_is_in_list(item.claims[INSTANCEPROP], + [target[INSTANCEPROP]])): + pywikibot.error( + f'Item {qnumber} {isbn_fmtd} is not an edition; not updated') + return 3 + + # Add missing book label for book language + if MULANG not in item.labels: + item.labels[MULANG] = objectname + item.editLabels(item.labels, summary=transcmt, bot=wdbotflag) + status = 'Found' else: - pywikibot.critical(f'Ambiguous ISBN number {isbn_fmtd}') - return + pywikibot.error( + f'Ambiguous ISBN number {isbn_fmtd}, ' + f'{[item.getID() for item in qnumber_list]} not updated' + ) + return 2 - # Add all P/Q values - # Make sure that labels are known in the native language - pywikibot.debug(target) + pywikibot.warning(f'{status} item {qnumber}: P212: {isbn_fmtd} ' + f'language {booklang} ({target[EDITIONLANGPROP]}) ' + f'{objectname}') - # Register statements - for propty in target: + # Register missing statements + pywikibot.debug(target) + for propty, title in target.items(): if propty not in item.claims: if propty not in proptyx: proptyx[propty] = pywikibot.PropertyPage(repo, propty) - targetx[propty] = pywikibot.ItemPage(repo, target[propty]) - try: - pywikibot.warning( - f'Add {proptyx[propty].labels[booklang]} ' - f'({propty}): {targetx[propty].labels[booklang]} ' - f'({target[propty]})' - ) - except: # noqa: B001, E722, H201 - pywikibot.warning(f'Add {propty}:{target[propty]}') + # Target could get overwritten locally + targetx[propty] = pywikibot.ItemPage(repo, title) claim = pywikibot.Claim(repo, propty) claim.setTarget(targetx[propty]) - item.addClaim(claim, bot=True, summary=transcmt) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Add {get_item_header_lang(proptyx[propty].labels, booklang)}' + f':{get_item_header_lang(targetx[propty].labels, booklang)} ' + f'({propty}:{title})' + ) + + # Set source reference + if booklib in bib_sourcex: + # A source reference can be only used once + # Expected error: + # "The provided Claim instance is already used in an entity" + # TODO: This error is sometimes raised without reason + try: + claim.addSources(booklib_ref, summary=transcmt) + except ValueError as error: + pywikibot.error(f'Source reference error, {error}') + + if (DESCRIBEDBYPROP not in item.claims + or not item_is_in_list(item.claims[DESCRIBEDBYPROP], + [bib_source[booklib][0]])): + claim = pywikibot.Claim(repo, DESCRIBEDBYPROP) + claim.setTarget(bib_sourcex[booklib]) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Add described by:{booklib} - {bib_source[booklib][1]} ' + f'({DESCRIBEDBYPROP}:{bib_source[booklib][0]})' + ) - # Set formatted ISBN number - if 'P212' not in item.claims: - pywikibot.warning(f'Add ISBN number (P212): {isbn_fmtd}') - claim = pywikibot.Claim(repo, 'P212') + if ISBNPROP not in item.claims: + # Create formatted ISBN-13 number + claim = pywikibot.Claim(repo, ISBNPROP) claim.setTarget(isbn_fmtd) - item.addClaim(claim, bot=True, summary=transcmt) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning(f'Add ISBN number ({ISBNPROP}) {isbn_fmtd}') + else: + for seq in item.claims[ISBNPROP]: + # Update unformatted to formatted ISBN-13 + if seq.getTarget() == isbn_number: + seq.changeTarget(isbn_fmtd, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Set formatted ISBN number ({ISBNPROP}): {isbn_fmtd}') + + if not isbn10_fmtd: + pass + elif ISBN10PROP in item.claims: + for seq in item.claims[ISBN10PROP]: + # Update unformatted to formatted ISBN-10 + if seq.getTarget() == isbn10_number: + seq.changeTarget(isbn10_fmtd, bot=wdbotflag, summary=transcmt) + pywikibot.warning('Set formatted ISBN-10 number ' + f'({ISBN10PROP}): {isbn10_fmtd}') + else: + # Create ISBN-10 number + claim = pywikibot.Claim(repo, ISBN10PROP) + claim.setTarget(isbn10_fmtd) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning(f'Add ISBN-10 number ({ISBN10PROP}) {isbn10_fmtd}') # Title - if 'P1476' not in item.claims: - pywikibot.warning(f'Add Title (P1476): {objectname}') - claim = pywikibot.Claim(repo, 'P1476') + if EDITIONTITLEPROP not in item.claims: + claim = pywikibot.Claim(repo, EDITIONTITLEPROP) claim.setTarget( pywikibot.WbMonolingualText(text=objectname, language=booklang)) - item.addClaim(claim, bot=True, summary=transcmt) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning(f'Add Title ({EDITIONTITLEPROP}) {objectname}') # Subtitle - if subtitle and 'P1680' not in item.claims: - pywikibot.warning(f'Add Subtitle (P1680): {subtitle}') - claim = pywikibot.Claim(repo, 'P1680') + if subtitle and EDITIONSUBTITLEPROP not in item.claims: + claim = pywikibot.Claim(repo, EDITIONSUBTITLEPROP) claim.setTarget( pywikibot.WbMonolingualText(text=subtitle, language=booklang)) - item.addClaim(claim, bot=True, summary=transcmt) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning(f'Add Subtitle ({EDITIONSUBTITLEPROP}): {subtitle}') # Date of publication pub_year = isbn_data['Year'] - if pub_year and 'P577' not in item.claims: - pywikibot.warning( - f"Add Year of publication (P577): {isbn_data['Year']}") - claim = pywikibot.Claim(repo, 'P577') + if pub_year and PUBYEARPROP not in item.claims: + claim = pywikibot.Claim(repo, PUBYEARPROP) claim.setTarget(pywikibot.WbTime(year=int(pub_year), precision='year')) - item.addClaim(claim, bot=True, summary=transcmt) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Add Year of publication ({PUBYEARPROP}): {isbn_data["Year"]}') - # Get the author list + # Set the author list author_cnt = 0 for author_name in isbn_data['Authors']: author_name = author_name.strip() @@ -525,131 +1163,224 @@ def add_claims(isbn_data: dict[str, Any]) -> None: # noqa: C901 continue author_cnt += 1 - author_list = list(get_item_list(author_name, propreqinst['P50'])) + # Reorder "lastname, firstname" and concatenate with space + author_name = get_canon_name(author_name) + author_list = get_item_list(author_name, propreqinst[AUTHORPROP]) if len(author_list) == 1: add_author = True - if 'P50' in item.claims: - for seq in item.claims['P50']: - if seq.getTarget().getID() in author_list: + author_item = author_list.pop() + + if (PROFESSIONPROP not in author_item.claims + or not item_is_in_list(author_item.claims[PROFESSIONPROP], + author_profession)): + # Add profession:author statement + claim = pywikibot.Claim(repo, PROFESSIONPROP) + claim.setTarget(target_author) + author_item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning('Add profession: author ' + f'({PROFESSIONPROP}:{AUTHORINSTANCE}) to ' + f'{author_name} ({author_item.getID()})') + + # Possibly found as author? + # Possibly found as editor? + # Possibly found as illustrator/photographer? + for prop in authorprop_list: + if prop not in item.claims: + continue + + for claim in item.claims[prop]: + book_author = claim.getTarget() + if book_author == author_item: + # Add missing sequence number + if SEQNRPROP not in claim.qualifiers: + qualifier = pywikibot.Claim(repo, SEQNRPROP) + qualifier.setTarget(str(author_cnt)) + claim.addQualifier(qualifier, bot=wdbotflag, + summary=transcmt) add_author = False break - if add_author: - pywikibot.warning(f'Add author {author_cnt} (P50): ' - f'{author_name} ({author_list[0]})') - claim = pywikibot.Claim(repo, 'P50') - claim.setTarget(pywikibot.ItemPage(repo, author_list[0])) - item.addClaim(claim, bot=True, summary=transcmt) + if item_has_label(book_author, author_name): + pywikibot.warning( + f'Edition has conflicting author ({prop}) ' + f'{author_name} ({book_author.getID()})' + ) + add_author = False + break - qualifier = pywikibot.Claim(repo, 'P1545') + if add_author: + claim = pywikibot.Claim(repo, AUTHORPROP) + claim.setTarget(author_item) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning(f'Add author {author_cnt}:{author_name} ' + f'({AUTHORPROP}:{author_item.getID()})') + + # Add sequence number + qualifier = pywikibot.Claim(repo, SEQNRPROP) qualifier.setTarget(str(author_cnt)) - claim.addQualifier(qualifier, summary=transcmt) - elif not author_list: - pywikibot.warning(f'Unknown author: {author_name}') + claim.addQualifier(qualifier, bot=wdbotflag, summary=transcmt) + elif author_list: + pywikibot.error( + f'Ambiguous author: {author_name}' + f'({[author_item.getID() for author_item in author_list]})' + ) else: - pywikibot.warning(f'Ambiguous author: {author_name}') + pywikibot.error(f'Unknown author: {author_name}') - # Get the publisher + # Set the publisher publisher_name = isbn_data['Publisher'].strip() if publisher_name: - publisher_list = list( - get_item_list(publisher_name, propreqinst['P123'])) + publisher_list = get_item_list(publisher_name, + propreqinst[PUBLISHERPROP]) if len(publisher_list) == 1: - if 'P123' not in item.claims: - pywikibot.warning(f'Add publisher (P123): {publisher_name} ' - f'({publisher_list[0]})') - claim = pywikibot.Claim(repo, 'P123') - claim.setTarget(pywikibot.ItemPage(repo, publisher_list[0])) - item.addClaim(claim, bot=True, summary=transcmt) - elif not publisher_list: - pywikibot.warning('Unknown publisher: ' + publisher_name) + publisher_item = publisher_list.pop() + if (PUBLISHERPROP not in item.claims + or not item_is_in_list(item.claims[PUBLISHERPROP], + [publisher_item.getID()])): + claim = pywikibot.Claim(repo, PUBLISHERPROP) + claim.setTarget(publisher_item) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Add publisher: {publisher_name} ' + f'({PUBLISHERPROP}:{publisher_item.getID()})' + ) + elif publisher_list: + pywikibot.error( + f'Ambiguous publisher: {publisher_name} ' + f'({[p_item.getID() for p_item in publisher_list]})' + ) else: - pywikibot.warning('Ambiguous publisher: ' + publisher_name) - - # Get addional data from the digital library - isbn_cover = isbnlib.cover(isbn_number) - isbn_editions = isbnlib.editions(isbn_number, service='merge') - isbn_doi = isbnlib.doi(isbn_number) - isbn_info = isbnlib.info(isbn_number) + pywikibot.error(f'Unknown publisher: {publisher_name}') - if verbose: - pywikibot.info() - pywikibot.info(isbn_info) - pywikibot.info(isbn_doi) - pywikibot.info(isbn_editions) + # Amend Written work relationship (one to many relationship) + if WRITTENWORKPROP in item.claims: + work = item.claims[WRITTENWORKPROP][0].getTarget() + if len(item.claims[WRITTENWORKPROP]) > 1: # Many to many (error) + pywikibot.error(f'Written work {work.getID()} is not unique') + else: + # Enhance data quality for Written work + if ISBNPROP in work.claims: + pywikibot.error(f'Written work {work.getID()} must not have' + ' an ISBN number') + + # Add written work instance + if (INSTANCEPROP not in work.claims + or not item_is_in_list(work.claims[INSTANCEPROP], + propreqinst[WRITTENWORKPROP])): + claim = pywikibot.Claim(repo, INSTANCEPROP) + claim.setTarget(get_item_page(propreqinst[WRITTENWORKPROP][0])) + work.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Add is a:written work instance ({INSTANCEPROP}:' + f'{propreqinst[WRITTENWORKPROP][0]}) ' + f'to written work {work.getID()}' + ) - # Book cover images - pywikibot.info(pformat(isbn_cover)) + # Check if inverse relationship to "edition of" exists + if (EDITIONPROP not in work.claims + or not item_is_in_list(work.claims[EDITIONPROP], + [qnumber])): + claim = pywikibot.Claim(repo, EDITIONPROP) + claim.setTarget(item) + work.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Add edition statement ({EDITIONPROP}:{qnumber}) to ' + f'written work {work.getID()}' + ) - # Handle ISBN classification - isbn_classify = isbnlib.classify(isbn_number) - pywikibot.debug(pformat(isbn_classify)) - - # ./create_isbn_edition.py '978-3-8376-5645-9' - de P407 Q188 - # Q113460204 - # {'owi': '11103651812', 'oclc': '1260160983', 'lcc': 'TK5105.8882', - # 'ddc': '300', 'fast': {'1175035': 'Wikis (Computer science)', - # '1795979': 'Wikipedia', - # '1122877': 'Social sciences'}} - - # Set the OCLC ID - if 'oclc' in isbn_classify and 'P243' not in item.claims: - pywikibot.warning(f"Add OCLC ID (P243): {isbn_classify['oclc']}") - claim = pywikibot.Claim(repo, 'P243') + # We need to first set the OCLC ID + # Because OCLC Work ID can be in conflict for edition + if 'oclc' in isbn_classify and OCLDIDPROP not in item.claims: + claim = pywikibot.Claim(repo, OCLDIDPROP) claim.setTarget(isbn_classify['oclc']) - item.addClaim(claim, bot=True, summary=transcmt) - - # OCLC ID and OCLC work ID should not be both assigned - if 'P243' in item.claims and 'P5331' in item.claims: - if 'P629' in item.claims: - oclcwork = item.claims['P5331'][0] # OCLC Work should be unique - # Get the OCLC Work ID from the edition - oclcworkid = oclcwork.getTarget() + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Add OCLC ID ({OCLDIDPROP}) {isbn_classify["oclc"]}') + + # OCLC ID and OCLC Work ID should not be both assigned + # Move OCLC Work ID to work if possible + if OCLDIDPROP in item.claims and OCLCWORKIDPROP in item.claims: + # Check if OCLC Work is available + oclcwork = item.claims[OCLCWORKIDPROP][0] # OCLC Work should be unique + # Get the OCLC Work ID from the edition + oclcworkid = oclcwork.getTarget() + + # Keep OCLC Work ID in edition if ambiguous + if len(item.claims[OCLCWORKIDPROP]) > 1: + pywikibot.error( + 'OCLC Work ID {work.getID()} is not unique; not moving') + elif WRITTENWORKPROP in item.claims: # Edition should belong to only one single work - work = item.claims['P629'][0].getTarget() - # There doesn't exist a moveClaim method? + work = item.claims[WRITTENWORKPROP][0].getTarget() pywikibot.warning( f'Move OCLC Work ID {oclcworkid} to work {work.getID()}') - # Keep current OCLC Work ID if present - if 'P5331' not in work.claims: - claim = pywikibot.Claim(repo, 'P5331') + + # Keep OCLC Work ID in edition if mismatch or ambiguity + if len(item.claims[WRITTENWORKPROP]) > 1: + pywikibot.error( + f'Written Work {work.getID()} is not unique; not moving') + elif OCLCWORKIDPROP not in work.claims: + claim = pywikibot.Claim(repo, OCLCWORKIDPROP) claim.setTarget(oclcworkid) - work.addClaim(claim, bot=True, summary=transcmt) - # OCLC Work ID does not belong to edition - item.removeClaims(oclcwork, bot=True, summary=transcmt) + work.addClaim(claim, bot=wdbotflag, + summary='#pwb Move OCLC Work ID') + pywikibot.warning( + f'Move OCLC Work ID ({OCLCWORKIDPROP}) {oclcworkid} to ' + f'written work {work.getID()}' + ) + + # OCLC Work ID does not belong to edition + item.removeClaims(oclcwork, bot=wdbotflag, + summary='#pwb Move OCLC Work ID') + elif is_in_value_list(work.claims[OCLCWORKIDPROP], oclcworkid): + # OCLC Work ID does not belong to edition + item.removeClaims(oclcwork, bot=wdbotflag, + summary='#pwb Remove redundant OCLC Work ID') + else: + pywikibot.error( + f'OCLC Work ID mismatch {oclcworkid} - ' + f'{work.claims[OCLCWORKIDPROP][0].getTarget()}; not moving' + ) else: - pywikibot.error('OCLC Work ID {} conflicts with OCLC ID {} and no ' - 'work available' - .format(item.claims['P5331'][0].getTarget(), - item.claims['P243'][0].getTarget())) + pywikibot.error(f'OCLC Work ID {oclcworkid} conflicts with OCLC ' + f'ID {item.claims[OCLDIDPROP][0].getTarget()} and' + ' no work available') # OCLC work ID should not be registered for editions, only for works if 'owi' not in isbn_classify: pass - elif 'P629' in item.claims: # Get the work related to the edition + elif WRITTENWORKPROP in item.claims: + # Get the work related to the edition # Edition should only have one single work - work = item.claims['P629'][0].getTarget() - if 'P5331' not in work.claims: # Assign the OCLC work ID if missing - pywikibot.warning( - f"Add OCLC work ID (P5331): {isbn_classify['owi']} to work " - f'{work.getID()}') - claim = pywikibot.Claim(repo, 'P5331') + # Assign the OCLC work ID if missing in work + work = item.claims[WRITTENWORKPROP][0].getTarget() + if (OCLCWORKIDPROP not in work.claims + or not is_in_value_list(work.claims[OCLCWORKIDPROP], + isbn_classify['owi'])): + claim = pywikibot.Claim(repo, OCLCWORKIDPROP) claim.setTarget(isbn_classify['owi']) - work.addClaim(claim, bot=True, summary=transcmt) - elif 'P243' in item.claims: - pywikibot.warning('OCLC Work ID {} ignored because of OCLC ID {}' - .format(isbn_classify['owi'], - item.claims['P243'][0].getTarget())) - # Assign the OCLC work ID only if there is no work, and no OCLC ID - # for edition - elif 'P5331' not in item.claims: + work.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Add OCLC work ID ({OCLCWORKIDPROP}) {isbn_classify["owi"]} ' + f'to work {work.getID()}' + ) + elif OCLDIDPROP in item.claims: pywikibot.warning( - f"Add OCLC work ID (P5331): {isbn_classify['owi']} to edition") - claim = pywikibot.Claim(repo, 'P5331') + f'OCLC Work ID {isbn_classify["owi"]} ignored because of OCLC ID' + f'{item.claims[OCLDIDPROP][0].getTarget()}' + ) + elif (OCLCWORKIDPROP not in item.claims + or not is_in_value_list(item.claims[OCLCWORKIDPROP], + isbn_classify['owi'])): + # Assign the OCLC work ID only if there is no work, and no OCLC ID for + # edition + claim = pywikibot.Claim(repo, OCLCWORKIDPROP) claim.setTarget(isbn_classify['owi']) - item.addClaim(claim, bot=True, summary=transcmt) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning(f'Add OCLC work ID ({OCLCWORKIDPROP}) ' + f'{isbn_classify["owi"]} to edition') # Reverse logic for moving OCLC ID and P212 (ISBN) from work to # edition is more difficult because of 1:M relationship... @@ -662,22 +1393,22 @@ def add_claims(isbn_data: dict[str, Any]) -> None: # noqa: C901 # registered for editions; should rather use P2969 # Library of Congress Classification (works and editions) - if 'lcc' in isbn_classify and 'P8360' not in item.claims: - pywikibot.warning( - 'Add Library of Congress Classification for edition (P8360): ' - f"{isbn_classify['lcc']}") - claim = pywikibot.Claim(repo, 'P8360') + if 'lcc' in isbn_classify and LIBCONGEDPROP not in item.claims: + claim = pywikibot.Claim(repo, LIBCONGEDPROP) claim.setTarget(isbn_classify['lcc']) - item.addClaim(claim, bot=True, summary=transcmt) - - # Dewey Decimale Classificatie - if 'ddc' in isbn_classify and 'P1036' not in item.claims: + item.addClaim(claim, bot=wdbotflag, summary=transcmt) pywikibot.warning( - f"Add Dewey Decimale Classificatie (P1036): {isbn_classify['ddc']}" + 'Add Library of Congress Classification for edition ' + f'({LIBCONGEDPROP}) {isbn_classify["lcc"]}' ) - claim = pywikibot.Claim(repo, 'P1036') + + # Dewey Decimale Classificatie + if 'ddc' in isbn_classify and DEWCLASIDPROP not in item.claims: + claim = pywikibot.Claim(repo, DEWCLASIDPROP) claim.setTarget(isbn_classify['ddc']) - item.addClaim(claim, bot=True, summary=transcmt) + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning(f'Add Dewey Decimale Classificatie ({DEWCLASIDPROP})' + f' {isbn_classify["ddc"]}') # Register Fast ID using P921 (main subject) through P2163 (Fast ID) # https://www.wikidata.org/wiki/Q3294867 @@ -690,147 +1421,274 @@ def add_claims(isbn_data: dict[str, Any]) -> None: # noqa: C901 # Corresponding to P921 (Wikidata main subject) if 'fast' in isbn_classify: for fast_id in isbn_classify['fast']: - - # Get the main subject - main_subject_query = f"""# Search the main subject -SELECT ?item WHERE {{ - ?item wdt:P2163 "{fast_id}". -}} -""" - - pywikibot.info(main_subject_query) - generator = pg.WikidataSPARQLPageGenerator(main_subject_query, - site=repo) - - # Main loop for all DISTINCT items - rescnt = 0 - for rescnt, main_subject in enumerate(generator, start=1): - qmain_subject = main_subject.getID() - try: - main_subject_label = main_subject.labels[booklang] - pywikibot.info(f'Found main subject {main_subject_label} ' - f'({qmain_subject}) for Fast ID {fast_id}') - except: # noqa: B001, E722, H201 - main_subject_label = '' - pywikibot.info(f'Found main subject ({qmain_subject}) for ' - f'Fast ID {fast_id}') - pywikibot.error(f'Missing label for item {qmain_subject}') - - # Create or amend P921 statement - if not rescnt: - pywikibot.error( - f'Main subject not found for Fast ID {fast_id}') - elif rescnt == 1: - add_main_subject = True - if 'P921' in item.claims: # Check for duplicates - for seq in item.claims['P921']: - if seq.getTarget().getID() == qmain_subject: - add_main_subject = False - break - - if add_main_subject: - pywikibot.warning( - f'Add main subject (P921) {main_subject_label} ' - f'({qmain_subject})') - claim = pywikibot.Claim(repo, 'P921') - claim.setTarget(main_subject) - item.addClaim(claim, bot=True, summary=transcmt) + # Get the main subject item number + qmain_subject = get_item_with_prop_value(FASTIDPROP, fast_id) + main_subject_label = isbn_classify['fast'][fast_id].lower() + + if len(qmain_subject) == 1: + # Get main subject and label + main_subject_label = get_item_header(qmain_subject[0].labels) + + if (MAINSUBPROP in item.claims + and item_is_in_list(item.claims[MAINSUBPROP], + [qmain_subject[0].getID()])): + pywikibot.log( + f'Skipping main subject ({MAINSUBPROP}): ' + f'{main_subject_label} ({qmain_subject[0]})' + ) else: - pywikibot.info(f'Skipping main subject ' - f'{main_subject_label} ({qmain_subject})') + claim = pywikibot.Claim(repo, MAINSUBPROP) + claim.setTarget(qmain_subject[0]) + # Add main subject + item.addClaim(claim, bot=wdbotflag, summary=transcmt) + pywikibot.warning( + f'Add main subject:{main_subject_label} ' + f'({MAINSUBPROP}:{qmain_subject[0]})' + ) + elif qmain_subject: + pywikibot.error(f'Ambiguous main subject for Fast ID {fast_id}' + f' - {main_subject_label}') else: - pywikibot.error( - f'Ambiguous main subject for Fast ID {fast_id}') - show_final_information(isbn_number, isbn_doi) + pywikibot.error(f'Main subject not found for Fast ID {fast_id}' + f' - {main_subject_label}') + + show_final_information(isbn_number) + return 0 -def show_final_information(number, doi): - """Print additional information.""" +def show_final_information(isbn_number: str) -> None: + """Print additional information. + + Get optional information.Could generate too many transactions errors; + so the process might stop at the first error. + """ # Book description - description = isbnlib.desc(number) - if description: + isbn_description = isbnlib.desc(isbn_number) + if isbn_description: pywikibot.info() - pywikibot.info(description) + pywikibot.info(isbn_description) - try: - bibtex_metadata = isbnlib.doi2tex(doi) - except Exception as error: - # Currently does not work (service not available) - pywikibot.error(error) # Data not available - pywikibot.warning('BibTex unavailable') - else: - pywikibot.info(bibtex_metadata) + # ISBN info + isbn_info = isbnlib.info(isbn_number) + if isbn_info: + pywikibot.info(isbn_info) + + # DOI number -- No warranty that the document number really exists on + # https:/doi.org + isbn_doi = isbnlib.doi(isbn_number) + if isbn_doi: + pywikibot.info(isbn_doi) + + # ISBN editions + isbn_editions = isbnlib.editions(isbn_number, service='merge') + if isbn_editions: + pywikibot.info(isbn_editions) + + # Book cover images + isbn_cover = isbnlib.cover(isbn_number) + for seq in isbn_cover: + pywikibot.info(f'{seq}: {isbn_cover[seq]}') + + # BibTex currently does not work (service not available); code was removed. def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. + **Algorithm:** + + :: + + Get parameters from shell + Validate parameters + Get ISBN data + Convert ISBN data: + Reverse names when Lastname, Firstname + Get additional data + Register ISBN data into Wikidata: + Add source reference when creating the item: + (digital library instance, retrieval date) + Create or amend items or claims: + Number the authors in order of appearence + Check data consistency + Correct data quality problems: + OCLC Work ID for Written work + Written work instance statement + Inverse relationship written work -> edition + Move/register OCLC work ID to/with written work + Manually corrections: + Create missing (referenced) items + (authors, publishers, written works, main subject/FAST ID) + Resolve ambiguous values + + :param args: command line arguments """ + global bib_sourcex global booklib + global booklib_ref + global exitstat global mainlang - global repo + global main_languages global proptyx + global repo + global target_author global targetx + global wdbotflag # Get optional parameters local_args = pywikibot.handle_args(*args) + # check dependencies + for module in (isbnlib, unidecode): + if isinstance(module, ImportError): + raise module + + # Connect to databases # Login to Wikibase instance # Required for wikidata object access (item, property, statement) repo = pywikibot.Site('wikidata') + repo.login() + + # Get language list + main_languages = get_language_preferences() + + # Get all program parameters + pywikibot.info(f'{pywikibot.calledModuleName()}, ' + f'{pywikibot.__version__}, {pgmlic}, {creator}') + + # This script requires a bot flag + wdbotflag = 'bot' in pywikibot.User(repo, repo.user()).groups() + + # Prebuilt targets + target_author = pywikibot.ItemPage(repo, AUTHORINSTANCE) + + # Get today's date + today = date.today() + date_ref = pywikibot.WbTime(year=int(today.strftime('%Y')), + month=int(today.strftime('%m')), + day=int(today.strftime('%d')), + precision='day') # Get the digital library + booklib = 'wiki' if local_args: booklib = local_args.pop(0) - if booklib == '-': - booklib = 'goob' + booklib = bookliblist.get(booklib, booklib) + + # Get ItemPage for digital library sources + bib_sourcex = {seq: get_item_page(bib_source[seq][0]) + for seq in bib_source} + + if booklib in bib_sourcex: + # Register source + references = pywikibot.Claim(repo, REFPROP) + references.setTarget(bib_sourcex[booklib]) + + # Set retrieval date + retrieved = pywikibot.Claim(repo, REFDATEPROP) + retrieved.setTarget(date_ref) + booklib_ref = [references, retrieved] + + # Get default language from book library + mainlang = bib_source[booklib][2] + else: + # Unknown bib reference - show implemented codes + for seq in bib_source: + pywikibot.info( + f'{seq.ljust(10)}{bib_source[seq][2].ljust(4)}' + f'{bib_source[seq][3].ljust(20)}{bib_source[seq][1]}' + ) + fatal_error(3, f'Unknown Digital library ({REFPROP}) {booklib}') + + # Get optional parameters (all are optional) # Get the native language # The language code is only required when P/Q parameters are added, - # or different from the LANG code + # or different from the environment LANG code if local_args: mainlang = local_args.pop(0) + if mainlang not in main_languages: + main_languages.insert(0, mainlang) + + pywikibot.info( + f'Refers to Digital library: {bib_source[booklib][1]} ' + f'({REFPROP}:{bib_source[booklib][0]}), language {mainlang}' + ) + # Get additional P/Q parameters while local_args: - inpar = PROPRE.findall(local_args.pop(0).upper())[0] - target[inpar] = QSUFFRE.findall(local_args.pop(0).upper())[0] + inpar = local_args.pop(0).upper() + inprop = PROPRE.findall(inpar)[0] + + if ':-' in inpar: + target[inprop] = '-' + else: + if ':Q' not in inpar: + inpar = local_args.pop(0).upper() + try: + target[inprop] = QSUFFRE.findall(inpar)[0] + except IndexError: + target[inprop] = '-' + break # Validate P/Q list proptyx = {} targetx = {} - # Validate the propery/instance pair - for propty in target: + # Validate and encode the propery/instance pair + for propty, title in target.items(): if propty not in proptyx: proptyx[propty] = pywikibot.PropertyPage(repo, propty) - targetx[propty] = pywikibot.ItemPage(repo, target[propty]) - targetx[propty].get(get_redirect=True) - if propty in propreqinst and ( - 'P31' not in targetx[propty].claims or not is_in_list( - targetx[propty].claims['P31'], propreqinst[propty])): - pywikibot.critical(f'{targetx[propty].labels[mainlang]} ' - f'({target[propty]}) is not a language') - return - - # check dependencies - for module in (isbnlib, unidecode): - if isinstance(module, ImportError): - raise module + if title != '-': + targetx[propty] = get_item_page(title) + pywikibot.info(f'Add {get_item_header(proptyx[propty].labels)}:' + f'{get_item_header(targetx[propty].labels)} ' + f'({propty}:{title})') + + # Check the instance type for P/Q pairs (critical) + if (propty in propreqinst + and (INSTANCEPROP not in targetx[propty].claims + or not item_is_in_list(targetx[propty].claims[INSTANCEPROP], + propreqinst[propty]))): + pywikibot.critical( + f'{get_item_header(targetx[propty].labels)} ({title})' + f' is not one of instance type {propreqinst[propty]} for ' + f'statement {get_item_header(proptyx[propty].labels)} ' + f'({propty})' + ) + sys.exit(3) + + # Verify that the target of a statement has a certain property + # (warning) + if (propty in propreqobjectprop + and not item_is_in_list(targetx[propty].claims, + propreqobjectprop[propty])): + pywikibot.error( + f'{get_item_header(targetx[propty].labels)} ({title})' + f' does not have property {propreqobjectprop[propty]} for ' + f'statement {get_item_header(proptyx[propty].labels)} ' + f'({propty})' + ) # Get list of item numbers # Typically the Appendix list of references of e.g. a Wikipedia page # containing ISBN numbers - inputfile = pywikibot.input('Get list of item numbers') - # Extract all ISBN numbers - itemlist = sorted(set(ISBNRE.findall(inputfile))) + # Extract all ISBN numbers from local args + itemlist = sorted(arg for arg in local_args if ISBNRE.fullmatch(arg)) for isbn_number in itemlist: # Process the next edition - amend_isbn_edition(isbn_number) + try: + exitstat = amend_isbn_edition(isbn_number) + except isbnlib.dev._exceptions.ISBNLibHTTPError: + pywikibot.exception() + except pywikibot.exceptions.Error as e: + pywikibot.error(e) + + sys.exit(exitstat) if __name__ == '__main__': diff --git a/scripts/data_ingestion.py b/scripts/data_ingestion.py index 0ee55dd3a6..97933af969 100755 --- a/scripts/data_ingestion.py +++ b/scripts/data_ingestion.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -r""" -A generic bot to do data ingestion (batch uploading) of photos or other files. +r"""A generic bot to do data ingestion (batch uploading) of photos or other files. In addition it installs related metadata. The uploading is primarily from a url to a wiki-site. @@ -120,8 +119,7 @@ class Photo(pywikibot.FilePage): def __init__(self, url: str, metadata: dict[str, Any], site: pywikibot.site.APISite | None = None) -> None: - """ - Initializer. + """Initializer. :param url: URL of photo :param metadata: metadata about the photo that can be referred to @@ -144,8 +142,7 @@ def __init__(self, url: str, metadata: dict[str, Any], super().__init__(site, self.get_title('%(_filename)s.%(_ext)s')) def download_photo(self) -> BinaryIO: - """ - Download the photo and store it in an io.BytesIO object. + """Download the photo and store it in an io.BytesIO object. TODO: Add exception handling """ @@ -155,8 +152,7 @@ def download_photo(self) -> BinaryIO: return self.contents def find_duplicate_images(self) -> list[str]: - """ - Find duplicates of the photo. + """Find duplicates of the photo. Calculates the SHA1 hash and asks the MediaWiki API for a list of duplicates. @@ -170,8 +166,7 @@ def find_duplicate_images(self) -> list[str]: sha1=base64.b16encode(hash_object.digest()))] def get_title(self, fmt: str) -> str: - """ - Populate format string with %(name)s entries using metadata. + """Populate format string with %(name)s entries using metadata. .. note:: this does not clean the title, so it may be unusable as a MediaWiki page title, and cause an API exception when used. @@ -215,8 +210,7 @@ class DataIngestionBot(pywikibot.Bot): """Data ingestion bot.""" def __init__(self, titlefmt: str, pagefmt: str, **kwargs) -> None: - """ - Initializer. + """Initializer. :param titlefmt: Title format :param pagefmt: Page format @@ -252,8 +246,7 @@ def treat(self, page) -> None: @classmethod def parse_configuration_page(cls, configuration_page) -> dict[str, str]: - """ - Parse a Page which contains the configuration. + """Parse a Page which contains the configuration. :param configuration_page: page with configuration :type configuration_page: :py:obj:`pywikibot.Page` @@ -282,8 +275,7 @@ def parse_configuration_page(cls, configuration_page) -> dict[str, str]: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/dataextend.py b/scripts/dataextend.py index 1a802cb229..76df0c0036 100755 --- a/scripts/dataextend.py +++ b/scripts/dataextend.py @@ -52,6 +52,8 @@ included. .. versionadded:: 7.2 +.. deprecated:: 9.6 + will be removed with Pywikibot 10. """ # # (C) Pywikibot team, 2020-2024 @@ -17728,8 +17730,7 @@ def findmixedrefs(self, html: str): def main(*args: tuple[str, ...]) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/delete.py b/scripts/delete.py index 977e3e5d00..05b4e3cf07 100755 --- a/scripts/delete.py +++ b/scripts/delete.py @@ -78,8 +78,7 @@ class PageWithRefs(Page): - """ - A subclass of Page with convenience methods for reference checking. + """A subclass of Page with convenience methods for reference checking. Supports the same interface as Page, with some added methods. """ @@ -99,8 +98,7 @@ def get_ref_table(self, *args, **kwargs) -> RefTable: @property def ref_table(self) -> RefTable: - """ - Build link reference table lazily. + """Build link reference table lazily. This property gives a default table without any parameter set for getReferences(), whereas self.get_ref_table() is able to accept @@ -111,8 +109,7 @@ def ref_table(self) -> RefTable: return self._ref_table def namespaces_with_ref_to_page(self, namespaces=None) -> set[Namespace]: - """ - Check if current page has links from pages in namepaces. + """Check if current page has links from pages in namepaces. If namespaces is None, all namespaces are checked. Returns a set with namespaces where a ref to page is present. @@ -148,8 +145,7 @@ def __init__(self, summary: str, **kwargs) -> None: self.generator = (PageWithRefs(p) for p in self.generator) def display_references(self) -> None: - """ - Display pages that link to the current page, sorted per namespace. + """Display pages that link to the current page, sorted per namespace. Number of pages to display per namespace is provided by: - self.opt.isorphan diff --git a/scripts/delinker.py b/scripts/delinker.py index a2b3c90f70..aa23d20a0e 100755 --- a/scripts/delinker.py +++ b/scripts/delinker.py @@ -222,8 +222,7 @@ def teardown(self): def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/djvutext.py b/scripts/djvutext.py index 6e8d30596e..91898e2c60 100755 --- a/scripts/djvutext.py +++ b/scripts/djvutext.py @@ -56,8 +56,7 @@ class DjVuTextBot(SingleSiteBot): - """ - A bot that uploads text-layer from djvu files to Page:namespace. + """A bot that uploads text-layer from djvu files to Page:namespace. Works only on sites with Proofread Page extension installed. @@ -77,8 +76,7 @@ def __init__( pages: tuple | None = None, **kwargs ) -> None: - """ - Initializer. + """Initializer. :param djvu: djvu from where to fetch the text layer :type djvu: DjVuFile object @@ -137,8 +135,7 @@ def treat(self, page) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/download_dump.py b/scripts/download_dump.py index 46a5890f17..6436b93235 100755 --- a/scripts/download_dump.py +++ b/scripts/download_dump.py @@ -186,8 +186,7 @@ def convert_from_bytes(total_bytes): def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/fixing_redirects.py b/scripts/fixing_redirects.py index d1aea725f7..a505c4e6fa 100755 --- a/scripts/fixing_redirects.py +++ b/scripts/fixing_redirects.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Correct all redirect links in featured pages or only one page of each wiki. +"""Correct all redirect links in featured pages or only one page of each wiki. Can be used with: @@ -200,8 +199,7 @@ def treat_page(self) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/harvest_template.py b/scripts/harvest_template.py index e495cc207e..91d2248e71 100755 --- a/scripts/harvest_template.py +++ b/scripts/harvest_template.py @@ -280,8 +280,7 @@ def template_link_target(item: pywikibot.ItemPage, return linked_item def _get_option_with_fallback(self, handler, option) -> Any: - """ - Compare bot's (global) and provided (local) options. + """Compare bot's (global) and provided (local) options. .. seealso:: :class:`OptionHandler` """ @@ -330,9 +329,9 @@ def treat_field(self, if not field or field not in self.fields: return - # todo: extend the list of tags to ignore + # TODO: extend the list of tags to ignore value = textlib.removeDisabledParts( - # todo: eventually we may want to import the references + # TODO: eventually we may want to import the references value, tags=['ref'], site=site).strip() if not value: @@ -510,8 +509,7 @@ def handle_commonsmedia( def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/illustrate_wikidata.py b/scripts/illustrate_wikidata.py index 67ca848596..135c1e0cfe 100755 --- a/scripts/illustrate_wikidata.py +++ b/scripts/illustrate_wikidata.py @@ -81,8 +81,7 @@ def treat_page_and_item(self, page, item) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/image.py b/scripts/image.py index 3bd14f6c44..c3d9d9e403 100755 --- a/scripts/image.py +++ b/scripts/image.py @@ -65,8 +65,7 @@ class ImageRobot(ReplaceBot): def __init__(self, generator, old_image: str, new_image: str = '', **kwargs) -> None: - """ - Initializer. + """Initializer. :param generator: the pages to work on :type generator: iterable @@ -121,8 +120,7 @@ def __init__(self, generator, old_image: str, def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/imagetransfer.py b/scripts/imagetransfer.py index 829e766cbb..c6f7f3b70b 100755 --- a/scripts/imagetransfer.py +++ b/scripts/imagetransfer.py @@ -202,8 +202,7 @@ def __init__(self, **kwargs) -> None: self.opt.target = pywikibot.Site(self.opt.target) def transfer_image(self, sourceImagePage) -> None: - """ - Download image and its description, and upload it to another site. + """Download image and its description, and upload it to another site. :return: the filename which was used to upload the image """ @@ -365,8 +364,7 @@ def transfer_allowed(self, image) -> bool: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/interwiki.py b/scripts/interwiki.py index 6b02cd5a17..bb298a3072 100755 --- a/scripts/interwiki.py +++ b/scripts/interwiki.py @@ -579,8 +579,7 @@ def readOptions(self, option: str) -> bool: class Subject(interwiki_graph.Subject): - """ - Class to follow the progress of a single 'subject'. + """Class to follow the progress of a single 'subject'. (i.e. a page with all its translations) @@ -651,8 +650,8 @@ def __init__(self, origin=None, hints=None, conf=None) -> None: super().__init__(origin) - # todo is a list of all pages that still need to be analyzed. - # Mark the origin page as todo. + # self.todo is a collection of all pages that still need to be + # analyzed. Mark the origin page as todo. self.todo = SizedKeyCollection('site') if origin: self.todo.append(origin) @@ -675,8 +674,7 @@ def __init__(self, origin=None, hints=None, conf=None) -> None: self.workonme = True def getFoundDisambig(self, site): - """ - Return the first disambiguation found. + """Return the first disambiguation found. If we found a disambiguation on the given site while working on the subject, this method returns it. If several ones have been found, the @@ -690,8 +688,7 @@ def getFoundDisambig(self, site): return None def getFoundNonDisambig(self, site): - """ - Return the first non-disambiguation found. + """Return the first non-disambiguation found. If we found a non-disambiguation on the given site while working on the subject, this method returns it. If several ones have been found, the @@ -708,8 +705,7 @@ def getFoundNonDisambig(self, site): return None def getFoundInCorrectNamespace(self, site): - """ - Return the first page in the extended namespace. + """Return the first page in the extended namespace. If we found a page that has the expected namespace on the given site while working on the subject, this method returns it. If several ones @@ -729,7 +725,7 @@ def getFoundInCorrectNamespace(self, site): return None def translate(self, hints=None, keephintedsites: bool = False) -> None: - """Add the given translation hints to the todo list.""" + """Add the given translation hints to the todo collection.""" if self.conf.same and self.origin: if hints: hints += ['all:'] @@ -755,8 +751,7 @@ def translate(self, hints=None, keephintedsites: bool = False) -> None: self.hintedsites.add(page.site) def openSites(self): - """ - Iterator. + """Iterator. Yields (site, count) pairs: * site is a site where we still have work to do on @@ -768,7 +763,8 @@ def whatsNextPageBatch(self, site) -> list[pywikibot.Page]: """Return the next page batch. By calling this method, you 'promise' this instance that you - will preload all the *site* Pages that are in the todo list. + will preload all the *site* Pages that are in the todo + collection. :return: This routine will return a list of pages that can be treated. @@ -797,8 +793,7 @@ def makeForcedStop(self, counter) -> None: self.forcedStop = True def addIfNew(self, page, counter, linkingPage) -> bool: - """ - Add the pagelink given to the todo list, if it hasn't been seen yet. + """Add the *page* to the todo collection, if it hasn't been seen yet. If it is added, update the counter accordingly. @@ -847,8 +842,7 @@ def get_alternative( return pywikibot.Page(site, title) if title else None def namespaceMismatch(self, linkingPage, linkedPage, counter) -> bool: - """ - Check whether or not the given page has a different namespace. + """Check whether or not the given page has a different namespace. Returns True if the namespaces are different and the user has selected not to follow the linked page. @@ -917,8 +911,7 @@ def namespaceMismatch(self, linkingPage, linkedPage, counter) -> bool: return False def disambigMismatch(self, page, counter): - """ - Check whether the given page has a different disambiguation status. + """Check whether the given page has a different disambiguation status. Returns a tuple (skip, alternativePage). @@ -1108,7 +1101,7 @@ def redir_checked(self, page, counter): return True def check_page(self, page, counter) -> None: - """Check whether any iw links should be added to the todo list.""" + """Check whether iw links should be added to the todo collection.""" try: ok = page.exists() except InvalidPageError as e: # T357953 @@ -1248,8 +1241,7 @@ def check_page(self, page, counter) -> None: break def batchLoaded(self, counter) -> None: - """ - Notify that the promised batch of pages was loaded. + """Notify that the promised batch of pages was loaded. This is called by a worker to tell us that the promised batch of pages was loaded. @@ -1283,7 +1275,7 @@ def batchLoaded(self, counter) -> None: counter.minus(page.site) # Now check whether any interwiki links should be added to the - # todo list. + # self.todo collection. self.check_page(page, counter) # These pages are no longer 'in progress' @@ -1416,11 +1408,10 @@ def assemble(self): return result def finish(self): - """ - Round up the subject, making any necessary changes. - - This should be called exactly once after the todo list has gone empty. + """Round up the subject, making any necessary changes. + This should be called exactly once after the todo collection has + gone empty. """ if not self.isDone(): raise Exception('Bugcheck: finish called before done') @@ -1589,7 +1580,7 @@ def replaceLinks(self, page, newPages) -> bool: if pltmp != page: pywikibot.error( f'{page} is not in the list of new links! Found {pltmp}.') - raise SaveError('BUG: sanity check failed') + raise SaveError('sanity check failed') # Avoid adding an iw link back to itself del new[page.site] @@ -1745,12 +1736,11 @@ def replaceLinks(self, page, newPages) -> bool: @staticmethod def reportBacklinks(new, updatedSites) -> None: - """ - Report missing back links. This will be called from finish() if needed. - - updatedSites is a list that contains all sites we changed, to avoid - reporting of missing backlinks for pages we already fixed + """Report missing back links. + This will be called from :meth:`finish` if needed. *updatedSites* + is a list that contains all sites that are changed, to avoid + reporting of missing backlinks for already fixed pages. """ # use sets because searching an element is faster than in lists expectedPages = set(new.values()) @@ -1801,8 +1791,7 @@ def reportBacklinks(new, updatedSites) -> None: class InterwikiBot: - """ - A class keeping track of a list of subjects. + """A class keeping track of a list of subjects. It controls which pages are queried from which languages when. """ @@ -1830,8 +1819,7 @@ def add(self, page, hints=None) -> None: self.plus(site, count) def setPageGenerator(self, pageGenerator, number=None, until=None) -> None: - """ - Add a generator of subjects. + """Add a generator of subjects. Once the list of subjects gets too small, this generator is called to produce more Pages. @@ -1910,8 +1898,7 @@ def firstSubject(self) -> Subject | None: return self.subjects[0] if self.subjects else None def maxOpenSite(self): - """ - Return the site that has the most open queries plus the number. + """Return the site that has the most open queries plus the number. If there is nothing left, return None. Only sites that are todo for the first Subject are returned. @@ -1965,8 +1952,7 @@ def selectQuerySite(self): return self.maxOpenSite() def oneQuery(self) -> bool: - """ - Perform one step in the solution process. + """Perform one step in the solution process. Returns True if pages could be preloaded, or false otherwise. @@ -2117,8 +2103,7 @@ def botMayEdit(page) -> bool: def page_empty_check(page) -> bool: - """ - Return True if page should be skipped as it is almost empty. + """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 diff --git a/scripts/interwikidata.py b/scripts/interwikidata.py index 018c606ba0..23767dd066 100755 --- a/scripts/interwikidata.py +++ b/scripts/interwikidata.py @@ -106,9 +106,8 @@ def treat_page(self) -> None: item = self.try_to_add() if self.opt.create and item is None: item = self.create_item() - else: - if self.opt.merge: - item = self.try_to_merge(item) + elif self.opt.merge: + item = self.try_to_merge(item) if item and self.opt.clean: self.current_item = item @@ -146,8 +145,7 @@ def create_item(self) -> pywikibot.ItemPage: return item def handle_complicated(self) -> bool: - """ - Handle pages when they have interwiki conflict. + """Handle pages when they have interwiki conflict. When this method returns True it means conflict has resolved and it's okay to clean old interwiki links. @@ -213,7 +211,7 @@ def try_to_merge(self, item) -> pywikibot.ItemPage | bool | None: """Merge two items.""" wd_data = self.get_items() if not wd_data: - # todo: add links to item + # TODO: add links to item return None if len(wd_data) > 1: @@ -232,8 +230,7 @@ def try_to_merge(self, item) -> pywikibot.ItemPage | bool | None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/listpages.py b/scripts/listpages.py index 64688ffddb..b052b2138a 100755 --- a/scripts/listpages.py +++ b/scripts/listpages.py @@ -136,8 +136,7 @@ class Formatter: fmt_need_lang = [k for k, v in fmt_options.items() if 'trs_title' in v] def __init__(self, page, outputlang=None, default: str = '******') -> None: - """ - Initializer. + """Initializer. :param page: the page to be formatted. :type page: Page object. @@ -271,8 +270,7 @@ def teardown(self) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/maintenance/cache.py b/scripts/maintenance/cache.py index e3cbb68f16..45674ffe41 100755 --- a/scripts/maintenance/cache.py +++ b/scripts/maintenance/cache.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -r""" -This script runs commands on each entry in the API caches. +r"""This script runs commands on each entry in the API caches. Syntax: @@ -445,11 +444,10 @@ def main(): if output: sys.exit('Only one output may be defined.') output = '' + elif not cache_paths: + cache_paths = [arg] else: - if not cache_paths: - cache_paths = [arg] - else: - cache_paths.append(arg) + cache_paths.append(arg) if not cache_paths: folders = ('apicache', 'apicache-py2', 'apicache-py3') diff --git a/scripts/maintenance/make_i18n_dict.py b/scripts/maintenance/make_i18n_dict.py index 5c22a3b20c..b8f361c6d0 100755 --- a/scripts/maintenance/make_i18n_dict.py +++ b/scripts/maintenance/make_i18n_dict.py @@ -112,8 +112,7 @@ def read(self, oldmsg, newmsg=None): print('WARNING: "en" key missing for message ' + newmsg) def run(self, quiet=False): - """ - Run the bot, read the messages from source and print the dict. + """Run the bot, read the messages from source and print the dict. :param quiet: print the result if False :type quiet: bool @@ -124,8 +123,7 @@ def run(self, quiet=False): self.print_all() def to_json(self, quiet=True): - """ - Run the bot and create json files. + """Run the bot and create json files. :param quiet: Print the result if False :type quiet: bool diff --git a/scripts/misspelling.py b/scripts/misspelling.py index ef3a50d674..12edc75ed7 100755 --- a/scripts/misspelling.py +++ b/scripts/misspelling.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -This script fixes links that contain common spelling mistakes. +"""This script fixes links that contain common spelling mistakes. This is only possible on wikis that have a template for these misspellings. @@ -105,8 +104,7 @@ def generator(self) -> Generator[pywikibot.Page]: yield from pagegenerators.PreloadingGenerator(chain(*generators)) def findAlternatives(self, page) -> bool: - """ - Append link target to a list of alternative links. + """Append link target to a list of alternative links. Overrides the BaseDisambigBot method. @@ -142,8 +140,7 @@ def findAlternatives(self, page) -> bool: return False def setSummaryMessage(self, page, *args, **kwargs) -> None: - """ - Setup the summary message. + """Setup the summary message. Overrides the BaseDisambigBot method. """ @@ -154,8 +151,7 @@ def setSummaryMessage(self, page, *args, **kwargs) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/movepages.py b/scripts/movepages.py index 266e369fab..8086cd9d56 100755 --- a/scripts/movepages.py +++ b/scripts/movepages.py @@ -212,8 +212,7 @@ def treat_page(self) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/newitem.py b/scripts/newitem.py index 6a05b84be0..a3301fbec9 100755 --- a/scripts/newitem.py +++ b/scripts/newitem.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -This script creates new items on Wikidata based on certain criteria. +"""This script creates new items on Wikidata based on certain criteria. * When was the (Wikipedia) page created? * When was the last edit on the page? @@ -182,8 +181,7 @@ def treat_page_and_item(self, page, item) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/noreferences.py b/scripts/noreferences.py index a13a1dcb0c..4c373e6d7d 100755 --- a/scripts/noreferences.py +++ b/scripts/noreferences.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -This script adds a missing references section to pages. +"""This script adds a missing references section to pages. It goes over multiple pages, searches for pages where <references /> is missing although a <ref> tag is present, and in that case adds a new @@ -620,8 +619,7 @@ def lacksReferences(self, text) -> bool: return True def addReferences(self, oldText) -> str: - """ - Add a references tag into an existing section where it fits into. + """Add a references tag into an existing section where it fits into. If there is no such section, creates a new section containing the references tag. Also repair malformed references tags. @@ -807,8 +805,7 @@ def treat_page(self) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/nowcommons.py b/scripts/nowcommons.py index e8bda2ceb7..02f60d7a1c 100755 --- a/scripts/nowcommons.py +++ b/scripts/nowcommons.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -r""" -Script to delete files that are also present on Wikimedia Commons. +r"""Script to delete files that are also present on Wikimedia Commons. Do not run this script on Wikimedia Commons itself. It works based on a given array of templates defined below. @@ -386,8 +385,7 @@ def teardown(self): def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/pagefromfile.py b/scripts/pagefromfile.py index 5cba3d1fac..1217bbfdce 100755 --- a/scripts/pagefromfile.py +++ b/scripts/pagefromfile.py @@ -103,8 +103,7 @@ def __init__(self, offset) -> None: class PageFromFileRobot(SingleSiteBot, CurrentPageBot): - """ - Responsible for writing pages to the wiki. + """Responsible for writing pages to the wiki. Titles and contents are given by a PageFromFileReader. @@ -170,9 +169,8 @@ def treat_page(self) -> None: else: pywikibot.info(f'Page {title} already exists, not adding!') return - else: - if self.opt.autosummary: - comment = config.default_edit_summary = '' + elif self.opt.autosummary: + comment = config.default_edit_summary = '' self.put_current(contents, summary=comment, minor=self.opt.minor, @@ -281,8 +279,7 @@ def find_page(self, text) -> tuple[int, str, str]: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/parser_function_count.py b/scripts/parser_function_count.py index 09a6aabfbb..5900508a6d 100755 --- a/scripts/parser_function_count.py +++ b/scripts/parser_function_count.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Used to find expensive templates that are subject to be converted to Lua. +"""Used to find expensive templates that are subject to be converted to Lua. It counts parser functions and then orders templates by number of these and uploads the first n titles or alternatively templates having count()>n. @@ -59,7 +58,7 @@ from pywikibot.bot import ExistingPageBot, SingleSiteBot -# Todo: +# TODO: # * Using xml and xmlstart # * Using categories # * Error handling for uploading (anyway, that's the last action, it's only diff --git a/scripts/patrol.py b/scripts/patrol.py index ec0259b6e1..410c1ea3c1 100755 --- a/scripts/patrol.py +++ b/scripts/patrol.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -The bot is meant to mark the edits based on info obtained by whitelist. +"""The bot is meant to mark the edits based on info obtained by whitelist. This bot obtains a list of recent changes and newpages and marks the edits as patrolled based on a whitelist. @@ -83,8 +82,7 @@ class PatrolBot(BaseBot): } def __init__(self, site=None, **kwargs) -> None: - """ - Initializer. + """Initializer. :keyword ask: If True, confirm each patrol action :keyword whitelist: page title for whitelist (optional) @@ -325,8 +323,7 @@ def treat(self, page): else: verbose_output('Skipped') - if rcid > self.highest_rcid: - self.highest_rcid = rcid + self.highest_rcid = max(rcid, self.highest_rcid) self.last_rcid = rcid diff --git a/scripts/protect.py b/scripts/protect.py index b9440d24eb..8150148d27 100755 --- a/scripts/protect.py +++ b/scripts/protect.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -This script can be used to protect and unprotect pages en masse. +"""This script can be used to protect and unprotect pages en masse. Of course, you will need an admin account on the relevant wiki. These command line parameters can be used to specify which pages to work on: @@ -87,8 +86,7 @@ class ProtectionRobot(SingleSiteBot, ConfigParserBot, CurrentPageBot): } def __init__(self, protections, **kwargs) -> None: - """ - Create a new ProtectionRobot. + """Create a new ProtectionRobot. :param protections: protections as a dict with "type": "level" :type protections: dict @@ -130,15 +128,15 @@ def check_protection_level(operation, level, levels, default=None) -> str: first_char = [] default_char = None num = 1 - for level in levels: - for c in level: + for lev in levels: + for c in lev: if c not in first_char: first_char.append(c) break else: first_char.append(str(num)) num += 1 - if level == default: + if lev == default: default_char = first_char[-1] choice = pywikibot.input_choice( @@ -150,8 +148,7 @@ def check_protection_level(operation, level, levels, default=None) -> str: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index 0db85408ae..51c254ddc8 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -7,7 +7,7 @@ package-dir = {"pywikibot_scripts" = "scripts"} [project] name = "pywikibot-scripts" -version = "9.5.0" +version = "9.6.0" authors = [ {name = "xqt", email = "info@gno.de"}, diff --git a/scripts/redirect.py b/scripts/redirect.py index 1e0cb11ab2..aeee362e90 100755 --- a/scripts/redirect.py +++ b/scripts/redirect.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Script to resolve double redirects, and to delete broken redirects. +"""Script to resolve double redirects, and to delete broken redirects. Requires access to MediaWiki's maintenance pages or to a XML dump file. Delete function requires adminship. @@ -139,8 +138,7 @@ def get_redirects_from_dump( self, alsoGetPageTitles: bool = False ) -> tuple[dict[str, str], set[str]]: - """ - Extract redirects from dump. + """Extract redirects from dump. Load a local XML dump file, look at all pages which have the redirect flag set, and find out where they're pointing at. Return @@ -230,8 +228,7 @@ def get_redirects_via_api( self, maxlen: int = 8 ) -> Generator[tuple[str, int | None, str, str | None]]: - r""" - Return a generator that yields tuples of data about redirect Pages. + r"""Return a generator that yields tuples of data about redirect Pages. .. versionchanged:: 7.0 only yield tuple if type of redirect is not 1 (normal redirect) @@ -693,8 +690,7 @@ def treat(self, page) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/reflinks.py b/scripts/reflinks.py index 95b719e67d..2f7f10e5a5 100755 --- a/scripts/reflinks.py +++ b/scripts/reflinks.py @@ -265,8 +265,7 @@ def transform(self, ispdf: bool = False) -> None: # TODO : remove HTML when both opening and closing tags are included def avoid_uppercase(self) -> None: - """ - Convert to title()-case if title is 70% uppercase characters. + """Convert to title()-case if title is 70% uppercase characters. Skip title that has less than 6 characters. """ @@ -744,8 +743,7 @@ def treat(self, page) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/replace.py b/scripts/replace.py index 5b04af1201..c4fc4a6d02 100755 --- a/scripts/replace.py +++ b/scripts/replace.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -r""" -This bot will make direct text replacements. +r"""This bot will make direct text replacements. It will retrieve information on which pages might need changes either from an XML dump or a text file, or only change a single page. @@ -325,8 +324,7 @@ def get_inside_exceptions(self): class ReplacementList(list): - """ - A list of replacements which all share some properties. + """A list of replacements which all share some properties. The shared properties are: * use_regex @@ -412,8 +410,7 @@ def get_inside_exceptions(self): class XmlDumpReplacePageGenerator: - """ - Iterator that will yield Pages that might contain text to replace. + """Iterator that will yield Pages that might contain text to replace. These pages will be retrieved from a local XML dump file. @@ -606,8 +603,7 @@ def isTextExcepted(self, text, exceptions=None) -> bool: return False def apply_replacements(self, original_text, applied, page=None): - """ - Apply all replacements to the given text. + """Apply all replacements to the given text. :rtype: str, set """ @@ -848,21 +844,20 @@ def handle_pairsfile(filename: str) -> list[str] | None: 'Please enter the filename to read replacements from:') try: - with Path(filename).open(encoding='utf-8') as f: - replacements = f.readlines() - if not replacements: + # use utf-8-sig to ignore BOM + content = Path(filename).read_text(encoding='utf-8-sig') + if not content: raise OSError(f'{filename} is empty.') except OSError as e: pywikibot.error(f'Error loading {filename}: {e}') return None + replacements = content.splitlines() if len(replacements) % 2: pywikibot.error(f'{filename} contains an incomplete pattern ' f'replacement pair:\n{replacements}') return None - # Strip BOM from first line - replacements[0].lstrip('\uFEFF') return replacements diff --git a/scripts/replicate_wiki.py b/scripts/replicate_wiki.py index 4f11c209cd..d77ee59b44 100755 --- a/scripts/replicate_wiki.py +++ b/scripts/replicate_wiki.py @@ -226,8 +226,7 @@ def check_page(self, pagename) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/revertbot.py b/scripts/revertbot.py index 94b1136162..53bcaadc2d 100755 --- a/scripts/revertbot.py +++ b/scripts/revertbot.py @@ -103,7 +103,7 @@ def local_timestamp(self, ts) -> str: year = formatYear(self.site.lang, ts.year) date = format_date(ts.month, ts.day, self.site) *_, time = str(ts).strip('Z').partition('T') - return ' '.join((date, year, time)) + return f'{date} {year} {time}' def revert(self, item) -> str | bool: """Revert a single item.""" @@ -155,8 +155,7 @@ def revert(self, item) -> str | bool: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/solve_disambiguation.py b/scripts/solve_disambiguation.py index 2f309d897d..3da88c5dc3 100755 --- a/scripts/solve_disambiguation.py +++ b/scripts/solve_disambiguation.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Script to help a human solve disambiguations by presenting a set of options. +"""Script to help a human solve disambiguations by presenting a set of options. Specify the disambiguation page on the command line. @@ -441,8 +440,7 @@ def __iter__(self) -> Generator[pywikibot.Page]: class PrimaryIgnoreManager: - """ - Primary ignore manager. + """Primary ignore manager. If run with the -primary argument, reads from a file which pages should not be worked on; these are the ones where the user pressed n last time. @@ -630,8 +628,7 @@ def __init__(self, *args, **kwargs) -> None: self.dn_template_str = i18n.translate(self.site, dn_template) def checkContents(self, text: str) -> str | None: # noqa: N802 - """ - Check if the text matches any of the ignore regexes. + """Check if the text matches any of the ignore regexes. :param text: wikitext of a page :return: None if none of the regular expressions @@ -677,7 +674,7 @@ def setup(self) -> None: \[\[ (?P<title> [^\[\]\|#]*) (?P<section> \#[^\]\|]*)? (\|(?P<label> [^\]]*))? \]\] - (?P<linktrail>{linktrail})""", flags=re.X) + (?P<linktrail>{linktrail})""", flags=re.VERBOSE) @staticmethod def firstlinks(page) -> Generator[str]: @@ -1166,22 +1163,21 @@ def setSummaryMessage( {'from': page.title(), 'to': targets, 'count': len(new_targets)}) + elif unlink_counter and not new_targets: + self.summary = i18n.twtranslate( + self.site, 'solve_disambiguation-links-removed', + {'from': page.title(), + 'count': unlink_counter}) + elif dn and not new_targets: + self.summary = i18n.twtranslate( + self.site, 'solve_disambiguation-adding-dn-template', + {'from': page.title()}) else: - if unlink_counter and not new_targets: - self.summary = i18n.twtranslate( - self.site, 'solve_disambiguation-links-removed', - {'from': page.title(), - 'count': unlink_counter}) - elif dn and not new_targets: - self.summary = i18n.twtranslate( - self.site, 'solve_disambiguation-adding-dn-template', - {'from': page.title()}) - else: - self.summary = i18n.twtranslate( - self.site, 'solve_disambiguation-links-resolved', - {'from': page.title(), - 'to': targets, - 'count': len(new_targets)}) + self.summary = i18n.twtranslate( + self.site, 'solve_disambiguation-links-resolved', + {'from': page.title(), + 'to': targets, + 'count': len(new_targets)}) def teardown(self) -> None: """Write ignoring pages to a file.""" @@ -1221,8 +1217,7 @@ def treat(self, page) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/speedy_delete.py b/scripts/speedy_delete.py index 1c753ed8a5..e7f1935ecb 100755 --- a/scripts/speedy_delete.py +++ b/scripts/speedy_delete.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Help sysops to quickly check and/or delete pages listed for speedy deletion. +"""Help sysops to quickly check and/or delete pages listed for speedy deletion. This bot trawls through candidates for speedy deletion in a fast and semi-automated fashion. It displays the contents of each page @@ -38,6 +37,7 @@ class SpeedyBot(SingleSiteBot, ExistingPageBot): + """Bot to delete pages which are tagged as speedy deletion. This bot will load a list of pages from the category of candidates for diff --git a/scripts/template.py b/scripts/template.py index d76108ace7..cc91a6ab98 100755 --- a/scripts/template.py +++ b/scripts/template.py @@ -134,8 +134,7 @@ class TemplateRobot(ReplaceBot): } def __init__(self, generator, templates: dict, **kwargs) -> None: - """ - Initializer. + """Initializer. :param generator: the pages to work on :type generator: iterable @@ -209,8 +208,7 @@ def __init__(self, generator, templates: dict, **kwargs) -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. @@ -257,10 +255,9 @@ def main(*args: str) -> None: skip = True elif arg.startswith('-timestamp:'): timestamp = arg[len('-timestamp:'):] - else: - if not gen_factory.handle_arg(arg): - template_name = pywikibot.Page(site, arg, ns=10) - template_names.append(template_name.title(with_ns=False)) + elif not gen_factory.handle_arg(arg): + template_name = pywikibot.Page(site, arg, ns=10) + template_names.append(template_name.title(with_ns=False)) if not template_names: pywikibot.bot.suggest_help(missing_parameters=['templates']) diff --git a/scripts/templatecount.py b/scripts/templatecount.py index cd4d744a01..266acc286f 100755 --- a/scripts/templatecount.py +++ b/scripts/templatecount.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Display the list of pages transcluding a given list of templates. +"""Display the list of pages transcluding a given list of templates. It can also be used to simply count the number of pages (rather than listing each individually). @@ -49,8 +48,7 @@ class TemplateCountRobot: @classmethod def count_templates(cls, templates, namespaces) -> None: - """ - Display number of transclusions for a list of templates. + """Display number of transclusions for a list of templates. Displays the number of transcluded page in the given 'namespaces' for each template given by 'templates' list. @@ -75,8 +73,7 @@ def count_templates(cls, templates, namespaces) -> None: @classmethod def list_templates(cls, templates, namespaces) -> None: - """ - Display transcluded pages for a list of templates. + """Display transcluded pages for a list of templates. Displays each transcluded page in the given 'namespaces' for each template given by 'templates' list. @@ -103,8 +100,7 @@ def list_templates(cls, templates, namespaces) -> None: @classmethod def template_dict(cls, templates, namespaces) -> dict[ str, list[pywikibot.Page]]: - """ - Create a dict of templates and its transcluded pages. + """Create a dict of templates and its transcluded pages. The names of the templates are the keys, and lists of pages transcluding templates in the given namespaces are the values. @@ -119,8 +115,7 @@ def template_dict(cls, templates, namespaces) -> dict[ @staticmethod def template_dict_generator(templates, namespaces) -> Generator[ tuple[str, list[pywikibot.Page]], None, None]: - """ - Yield transclusions of each template in 'templates'. + """Yield transclusions of each template in 'templates'. For each template in 'templates', yield a tuple (template, transclusions), where 'transclusions' is a list of all pages @@ -140,8 +135,7 @@ def template_dict_generator(templates, namespaces) -> Generator[ def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/touch.py b/scripts/touch.py index 9004e634ee..2793c36c2e 100755 --- a/scripts/touch.py +++ b/scripts/touch.py @@ -137,8 +137,7 @@ def purgepages(self, flush=False): def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/transferbot.py b/scripts/transferbot.py index 6c8b3b67c9..252bde8041 100755 --- a/scripts/transferbot.py +++ b/scripts/transferbot.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -r""" -This script transfers pages from a source wiki to a target wiki. +r"""This script transfers pages from a source wiki to a target wiki. It also copies edit history to a subpage. @@ -61,8 +60,7 @@ def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/transwikiimport.py b/scripts/transwikiimport.py index dc493cc1ab..67bcfb59a1 100755 --- a/scripts/transwikiimport.py +++ b/scripts/transwikiimport.py @@ -161,8 +161,7 @@ def api_query(site, params: dict[str, str]): def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. @@ -290,13 +289,12 @@ def main(*args: str) -> None: ' exists)' ) continue - else: - if not targetpage.botMayEdit(): - pywikibot.warning( - f'Target page {targetpage.title(as_link=True)} is not' - ' editable by bots' - ) - continue + elif not targetpage.botMayEdit(): + pywikibot.warning( + f'Target page {targetpage.title(as_link=True)} is not' + ' editable by bots' + ) + continue params['interwikipage'] = fromtitle api_query(tosite, params) diff --git a/scripts/unusedfiles.py b/scripts/unusedfiles.py index 82feda7f30..0618b5193c 100755 --- a/scripts/unusedfiles.py +++ b/scripts/unusedfiles.py @@ -162,8 +162,7 @@ def append_text(self, page, apptext): def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/upload.py b/scripts/upload.py index 07d9aec5fe..c766801012 100755 --- a/scripts/upload.py +++ b/scripts/upload.py @@ -83,7 +83,7 @@ CHUNK_SIZE_REGEX = re.compile( - r'-chunked(?::(\d+(?:\.\d+)?)[ \t]*(k|ki|m|mi)?b?)?', re.I) + r'-chunked(?::(\d+(?:\.\d+)?)[ \t]*(k|ki|m|mi)?b?)?', re.IGNORECASE) def get_chunk_size(match) -> int: @@ -112,8 +112,7 @@ def get_chunk_size(match) -> int: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. @@ -223,8 +222,9 @@ def main(*args: str) -> None: if not recursive: # Do not visit any subdirectories directory_info[1][:] = [] - for dir_file in directory_info[2]: - file_list.append(os.path.join(directory_info[0], dir_file)) + + file_list += [os.path.join(directory_info[0], dir_file) + for dir_file in directory_info[2]] url = file_list else: url = [url] diff --git a/scripts/watchlist.py b/scripts/watchlist.py index f6c1df66d3..d5a2e1c901 100755 --- a/scripts/watchlist.py +++ b/scripts/watchlist.py @@ -122,8 +122,7 @@ def refresh_new() -> None: def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/weblinkchecker.py b/scripts/weblinkchecker.py index bd58cafb7b..daaa880263 100755 --- a/scripts/weblinkchecker.py +++ b/scripts/weblinkchecker.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -This bot is used for checking external links found at the wiki. +"""This bot is used for checking external links found at the wiki. It checks several pages at once, with a limit set by the config variable max_external_links, which defaults to 50. @@ -190,8 +189,7 @@ def weblinks_from_text( without_bracketed: bool = False, only_bracketed: bool = False ): - """ - Yield web links from text. + """Yield web links from text. Only used as text predicate for XmlDumpPageGenerator to speed up generator. @@ -323,8 +321,7 @@ def run(self): class History: - """ - Store previously found dead links. + """Store previously found dead links. The URLs are dictionary keys, and values are lists of tuples where each tuple represents one time the URL was @@ -366,16 +363,16 @@ def __init__(self, report_thread, site=None) -> None: # no saved history exists yet, or history dump broken self.history_dict = {} - def log(self, url, error, containing_page, archive_url) -> None: + def log(self, url, containing_page, archive_url) -> None: """Log an error report to a text file in the deadlinks subdirectory.""" if archive_url: error_report = f'* {url} ([{archive_url} archive])\n' else: error_report = f'* {url}\n' - for (page_title, date, error) in self.history_dict[url]: + for (page_title, date, err) in self.history_dict[url]: # ISO 8601 formulation iso_date = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(date)) - error_report += f'** In [[{page_title}]] on {iso_date}, {error}\n' + error_report += f'** In [[{page_title}]] on {iso_date}, {err}\n' pywikibot.info('** Logging link for deletion.') txtfilename = pywikibot.config.datafilepath( 'deadlinks', @@ -414,13 +411,12 @@ def set_dead_link(self, url, error, page, weblink_dead_days) -> None: pywikibot.warning( f'get_closest_memento_url({url}) failed: {e}') archive_url = None - self.log(url, error, page, archive_url) + self.log(url, page, archive_url) else: self.history_dict[url] = [(page.title(), now, error)] def set_link_alive(self, url) -> bool: - """ - Record that the link is now alive. + """Record that the link is now alive. If link was previously found dead, remove it from the .dat file. @@ -441,8 +437,7 @@ def save(self) -> None: class DeadLinkReportThread(threading.Thread): - """ - A Thread that is responsible for posting error reports on talk pages. + """A Thread that is responsible for posting error reports on talk pages. There is only one DeadLinkReportThread, and it is using a semaphore to make sure that two LinkCheckerThreads cannot access the queue at the same time. @@ -536,8 +531,7 @@ def run(self) -> None: class WeblinkCheckerRobot(SingleSiteBot, ExistingPageBot): - """ - Bot which will search for dead weblinks. + """Bot which will search for dead weblinks. It uses several LinkCheckThreads at once to process pages from generator. """ @@ -640,8 +634,7 @@ def RepeatPageGenerator(): # noqa: N802 def main(*args: str) -> None: - """ - Process command line arguments and invoke bot. + """Process command line arguments and invoke bot. If args is an empty list, sys.argv is used. diff --git a/scripts/welcome.py b/scripts/welcome.py index 18a4be13c4..4d86298b0a 100755 --- a/scripts/welcome.py +++ b/scripts/welcome.py @@ -429,8 +429,6 @@ 'zh': '<small>(via ~~~)</small>', } -# -# LOGPAGE_HEADER = { '_default': '{|border="2" cellpadding="4" cellspacing="0" style="margin: ' '0.5em 0.5em 0.5em 1em; padding: 0.5em; background: #bfcda5; ' @@ -754,7 +752,7 @@ def define_sign(self, force: bool = False) -> list[str]: return self._random_signature sign_text = '' - creg = re.compile(r'^\* ?(.*?)$', re.M) + creg = re.compile(r'^\* ?(.*?)$', re.MULTILINE) if not globalvar.sign_file_name: sign_page_name = i18n.translate(self.site, RANDOM_SIGN) if not sign_page_name: diff --git a/setup.py b/setup.py index 3932956328..3f3953eafb 100755 --- a/setup.py +++ b/setup.py @@ -49,7 +49,10 @@ 'Pillow>=8.1.2, != 10.0, != 10.1; python_version < "3.13"', 'Pillow>=10.4; python_version >= "3.13"', ], - 'mwoauth': ['mwoauth!=0.3.1,>=0.2.4'], + 'mwoauth': [ + 'PyJWT != 2.10.0, != 2.10.1; python_version > "3.8"', # T380270 + 'mwoauth!=0.3.1,>=0.2.4', + ], 'html': ['beautifulsoup4>=4.7.1'], 'http': ['fake-useragent>=1.4.0'], } diff --git a/tests/__init__.py b/tests/__init__.py index 8eb9a7a4a5..3fc5d44c53 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -136,6 +136,7 @@ def create_path_func(base_func, subpath): 'uploadbot', 'user', 'version', + 'wbtypes', 'wikibase', 'wikibase_edit', 'wikiblame', @@ -238,12 +239,12 @@ def collector(loader=unittest.loader.defaultTestLoader): discovered = loader.loadTestsFromName(module_class_name) enabled_tests = [] for cls in discovered: - for test_func in cls: - if test_func._testMethodName not in disabled_tests[module]: - enabled_tests.append( - module_class_name + '.' - + test_func.__class__.__name__ + '.' - + test_func._testMethodName) + enabled_tests += [ + f'{module_class_name}.{type(test_func).__name__}.' + f'{test_func._testMethodName}' + for test_func in cls + if test_func._testMethodName not in disabled_tests[module] + ] test_list.extend(enabled_tests) else: diff --git a/tests/api_tests.py b/tests/api_tests.py index 14e56c9962..8c21f4d594 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -779,8 +779,7 @@ def test_internals(self): class TestLazyLoginBase(TestCase): - """ - Test that it tries to login when read API access is denied. + """Test that it tries to login when read API access is denied. Because there is no such family configured it creates an AutoFamily and BaseSite on it's own. It's testing against steward.wikimedia.org. diff --git a/tests/archivebot_tests.py b/tests/archivebot_tests.py index 479e57b16b..49f2c780fd 100755 --- a/tests/archivebot_tests.py +++ b/tests/archivebot_tests.py @@ -163,8 +163,7 @@ def test_archivebot(self, code=None): class TestArchiveBotAfterDateUpdate(TestCase): - """ - Test archivebot script on failures on Wikipedia sites. + """Test archivebot script on failures on Wikipedia sites. If failure is due to updated date format on wiki, test pages with new format only. diff --git a/tests/aspects.py b/tests/aspects.py index 7a6da4e6d7..6fefe0cb39 100644 --- a/tests/aspects.py +++ b/tests/aspects.py @@ -1,5 +1,4 @@ -""" -Test aspects to allow fine grained control over what tests are executed. +"""Test aspects to allow fine grained control over what tests are executed. Several parts of the test infrastructure are implemented as mixins, such as API result caching and excessive test durations. @@ -241,8 +240,7 @@ def assertPageTitlesCountEqual( def assertAPIError(self, code, info=None, callable_obj=None, *args, regex=None, **kwargs): - """ - Assert that a specific APIError wrapped around :py:obj:`assertRaises`. + """Assert that a specific APIError wrapped around :exc:`assertRaises`. If no callable object is defined and it returns a context manager, that context manager will return the underlying context manager used by @@ -438,8 +436,7 @@ class CheckHostnameMixin(TestCaseBase): @classmethod def setUpClass(cls): - """ - Set up the test class. + """Set up the test class. Prevent tests running if the host is down. """ @@ -579,8 +576,7 @@ def setUpClass(cls): f'{cls.__name__}: Not able to login to {site}') def setUp(self): - """ - Set up the test case. + """Set up the test case. Login to the site if it is not logged in. """ @@ -892,8 +888,7 @@ class TestCase(TestCaseBase, metaclass=MetaTestCaseClass): @classmethod def setUpClass(cls): - """ - Set up the test class. + """Set up the test class. Prefetch the Site object for each of the sites the test class has declared are needed. @@ -1058,8 +1053,7 @@ def add_patch(decorated): return add_patch def patch(self, obj, attr_name, replacement): - """ - Patch the obj's attribute with the replacement. + """Patch the obj's attribute with the replacement. It will be reset after each ``tearDown``. """ @@ -1105,8 +1099,7 @@ class DefaultSiteTestCase(TestCase): @classmethod def override_default_site(cls, site): - """ - Override the default site. + """Override the default site. :param site: site tests should use :type site: BaseSite @@ -1200,8 +1193,7 @@ class WikibaseTestCase(TestCase): @classmethod def setUpClass(cls): - """ - Set up the test class. + """Set up the test class. Checks that all sites are configured with a Wikibase repository, with Site.has_data_repository() returning True, and all sites @@ -1249,8 +1241,7 @@ class WikibaseClientTestCase(WikibaseTestCase): @classmethod def setUpClass(cls): - """ - Set up the test class. + """Set up the test class. Checks that all sites are configured as a Wikibase client, with Site.has_data_repository returning True. @@ -1285,8 +1276,7 @@ class DefaultWikidataClientTestCase(DefaultWikibaseClientTestCase): @classmethod def setUpClass(cls): - """ - Set up the test class. + """Set up the test class. Require the data repository is wikidata.org. """ @@ -1446,8 +1436,7 @@ def _build_message(cls, return msg def assertDeprecationParts(self, deprecated=None, instead=None): - """ - Assert that a deprecation warning happened. + """Assert that a deprecation warning happened. To simplify deprecation tests it just requires the to separated parts and forwards the result to :py:obj:`assertDeprecation`. @@ -1465,8 +1454,7 @@ def assertDeprecationParts(self, deprecated=None, instead=None): self.assertDeprecation(self._build_message(deprecated, instead)) def assertDeprecation(self, msg=None): - """ - Assert that a deprecation warning happened. + """Assert that a deprecation warning happened. :param msg: Either the specific message or None to allow any generic message. When set to ``INSTEAD`` it only counts those supplying an @@ -1497,8 +1485,7 @@ def assertDeprecation(self, msg=None): def assertOneDeprecationParts(self, deprecated=None, instead=None, count=1): - """ - Assert that exactly one deprecation message happened and reset. + """Assert that exactly one deprecation message happened and reset. It uses the same arguments as :py:obj:`assertDeprecationParts`. """ @@ -1564,8 +1551,7 @@ def tearDown(self): class HttpbinTestCase(TestCase): - """ - Custom test case class, which allows dry httpbin tests with pytest-httpbin. + """Custom test case class, which allows dry httpbin tests. Test cases, which use httpbin, need to inherit this class. """ diff --git a/tests/basepage.py b/tests/basepage.py index 50880971c9..21ced3026e 100644 --- a/tests/basepage.py +++ b/tests/basepage.py @@ -25,8 +25,7 @@ def setUp(self): class BasePageLoadRevisionsCachingTestBase(BasePageTestBase): - """ - Test site.loadrevisions() caching. + """Test site.loadrevisions() caching. This test class monkey patches site.loadrevisions, which will cause the pickling tests in site_tests and page_tests to fail, if it diff --git a/tests/bot_tests.py b/tests/bot_tests.py index 0f5cb99a53..7641abb72c 100755 --- a/tests/bot_tests.py +++ b/tests/bot_tests.py @@ -40,8 +40,7 @@ class TestBotTreatExit: """Mixin to provide handling for treat and exit.""" def _treat(self, pages, post_treat=None): - """ - Get tests which are executed on each treat. + """Get tests which are executed on each treat. It uses pages as an iterator and compares the page given to the page returned by pages iterator. It checks that the bot's _site and site @@ -71,8 +70,7 @@ def treat(page): return treat def _treat_page(self, pages=True, post_treat=None): - """ - Adjust to CurrentPageBot signature. + """Adjust to CurrentPageBot signature. It uses almost the same logic as _treat but returns a wrapper function which itself calls the function returned by _treat. diff --git a/tests/cache_tests.py b/tests/cache_tests.py index 837c51ecba..b02bf40bf8 100755 --- a/tests/cache_tests.py +++ b/tests/cache_tests.py @@ -9,9 +9,9 @@ import unittest -import scripts.maintenance.cache as cache from pywikibot.login import LoginStatus from pywikibot.site import BaseSite +from scripts.maintenance import cache from tests import join_cache_path from tests.aspects import TestCase diff --git a/tests/category_bot_tests.py b/tests/category_bot_tests.py index 0ce04ce8f9..13ba895f72 100755 --- a/tests/category_bot_tests.py +++ b/tests/category_bot_tests.py @@ -47,27 +47,19 @@ def test_strip_cfd_templates_without_spaces_in_comments(self): def _runtest_strip_cfd_templates(self, template_start, template_end): """Run a CFD template stripping test, given CFD start/end templates.""" bot = CategoryMoveRobot(oldcat='Old', newcat='New') - bot.newcat.text = '\n'.join(( - 'Preamble', - template_start, - 'Random text inside template', - 'Even another template: {{cfr-speedy}}', - template_end, - 'Footer stuff afterwards', - '', - '[[Category:Should remain]]' - )) - expected = '\n'.join(( - 'Preamble', - 'Footer stuff afterwards', - '', - '[[Category:Should remain]]' - )) + bot.newcat.text = ( + f'Preamble\n{template_start}\nRandom text inside template\n' + f'Even another template: {{{{cfr-speedy}}}}\n{template_end}\n' + f'Footer stuff afterwards\n\n[[Category:Should remain]]' + ) + expected = ('Preamble\nFooter stuff afterwards\n\n' + '[[Category:Should remain]]') bot._strip_cfd_templates(commit=False) self.assertEqual(bot.newcat.text, expected) class TestPreprocessingCategory(TestCase): + """Test determining template or type categorization target.""" family = 'wikipedia' diff --git a/tests/cosmetic_changes_tests.py b/tests/cosmetic_changes_tests.py index 251d38b8f3..538cda2d12 100755 --- a/tests/cosmetic_changes_tests.py +++ b/tests/cosmetic_changes_tests.py @@ -434,8 +434,7 @@ def test_translate_magic_words(self): @unittest.expectedFailure def test_translateMagicWords_fail(self): - """ - Test translateMagicWords method. + """Test translateMagicWords method. The current implementation doesn't check whether the magic word is inside a template. @@ -582,31 +581,31 @@ def test_invalid_isbn(self): # Invalid characters with self.assertRaisesRegex( ValidationError, - '|'.join((self.ISBN_DIGITERROR_RE, - self.ISBN_INVALIDERROR_RE, - self.ISBN_INVALIDLENGTHERROR_RE))): + f'{self.ISBN_DIGITERROR_RE}|{self.ISBN_INVALIDERROR_RE}|' + f'{self.ISBN_INVALIDLENGTHERROR_RE}' + ): self.cct.fix_ISBN('ISBN 0975229LOL') # Invalid checksum with self.assertRaisesRegex( ValidationError, - '|'.join((self.ISBN_CHECKSUMERROR_RE, - self.ISBN_INVALIDERROR_RE, - self.ISBN_INVALIDLENGTHERROR_RE, - self.ISBN_INVALIDCHECKERROR_RE))): + f'{self.ISBN_CHECKSUMERROR_RE}|{self.ISBN_INVALIDERROR_RE}|' + f'{self.ISBN_INVALIDLENGTHERROR_RE}|' + f'{self.ISBN_INVALIDCHECKERROR_RE}' + ): self.cct.fix_ISBN('ISBN 0975229801') # Invalid length with self.assertRaisesRegex( ValidationError, - '|'.join((self.ISBN_DIGITERROR_RE, - self.ISBN_INVALIDERROR_RE, - self.ISBN_INVALIDLENGTHERROR_RE))): + f'{self.ISBN_DIGITERROR_RE}|{self.ISBN_INVALIDERROR_RE}|' + f'{self.ISBN_INVALIDLENGTHERROR_RE}' + ): self.cct.fix_ISBN('ISBN 09752298') # X in the middle with self.assertRaisesRegex( ValidationError, - '|'.join((self.ISBN_INVALIDCHARERROR_RE, - self.ISBN_INVALIDERROR_RE, - self.ISBN_INVALIDLENGTHERROR_RE))): + f'{self.ISBN_INVALIDCHARERROR_RE}|{self.ISBN_INVALIDERROR_RE}|' + f'{self.ISBN_INVALIDLENGTHERROR_RE}' + ): self.cct.fix_ISBN('ISBN 09752X9801') def test_ignore_invalid_isbn(self): diff --git a/tests/data/pairsfile BOM.txt b/tests/data/pairsfile BOM.txt new file mode 100644 index 0000000000..8096d66d3b --- /dev/null +++ b/tests/data/pairsfile BOM.txt @@ -0,0 +1,4 @@ +Category:Notice Board Quests +Категория:Задания с Доски объявлений +Windbluff Tower Key +Ключ от Крепости Ветров diff --git a/tests/data/pairsfile.txt b/tests/data/pairsfile.txt new file mode 100644 index 0000000000..927e6086db --- /dev/null +++ b/tests/data/pairsfile.txt @@ -0,0 +1,4 @@ +Category:Notice Board Quests +Категория:Задания с Доски объявлений +Windbluff Tower Key +Ключ от Крепости Ветров diff --git a/tests/dry_api_tests.py b/tests/dry_api_tests.py index a49c66c160..76746251b4 100755 --- a/tests/dry_api_tests.py +++ b/tests/dry_api_tests.py @@ -169,8 +169,9 @@ def version(self): def protocol(self): return 'http' - def languages(self): - return ['mock'] + @property + def codes(self): + return {'mock'} def user(self): return self._user diff --git a/tests/edit_tests.py b/tests/edit_tests.py index 7fb89e9e6a..9f3e3515c4 100755 --- a/tests/edit_tests.py +++ b/tests/edit_tests.py @@ -72,6 +72,7 @@ def test_appendtext(self): class TestSiteMergeHistory(TestCase): + """Test history merge action.""" family = 'wikipedia' diff --git a/tests/fixing_redirects_tests.py b/tests/fixing_redirects_tests.py index a60b121fc9..d1597a23a0 100755 --- a/tests/fixing_redirects_tests.py +++ b/tests/fixing_redirects_tests.py @@ -13,6 +13,7 @@ class TestFixingRedirects(TestCase): + """Test fixing redirects.""" family = 'wikipedia' diff --git a/tests/flow_tests.py b/tests/flow_tests.py index aa718b07cd..62e19f5734 100755 --- a/tests/flow_tests.py +++ b/tests/flow_tests.py @@ -239,6 +239,7 @@ def test_invalid_data(self): class TestFlowTopic(TestCase): + """Test Topic functions.""" family = 'wikipedia' diff --git a/tests/gui_tests.py b/tests/gui_tests.py index 3d15388693..91cb9759b4 100755 --- a/tests/gui_tests.py +++ b/tests/gui_tests.py @@ -84,11 +84,19 @@ def setUpModule(): # pypy3 has a tkinter module which just raises importError if _tkinter # is not installed; thus require_modules does not work for it. + # pypy3.10 has a version mismatch, see T380732. try: import tkinter except ImportError as e: raise unittest.SkipTest(e) + try: + dialog = tkinter.Tk() + except RuntimeError as e: + raise unittest.SkipTest(f'Skipping due to T380732 - {e}') + else: + dialog.destroy() + from pywikibot.userinterfaces.gui import EditBoxWindow, Tkdialog diff --git a/tests/http_tests.py b/tests/http_tests.py index 5139c11e90..f94250fb85 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-2023 +# (C) Pywikibot team, 2014-2024 # # Distributed under the terms of the MIT license. # @@ -520,8 +520,7 @@ def test_no_params(self): self.assertEqual(r.json()['args'], {}) def test_unencoded_params(self): - """ - Test fetch method with unencoded parameters to be encoded internally. + """Test fetch method with unencoded parameters to be encoded inside. HTTPBin returns the args in their urldecoded form, so what we put in should be the same as what we get out. @@ -537,8 +536,7 @@ def test_unencoded_params(self): self.assertEqual(r.json()['args'], {'fish&chips': 'delicious'}) def test_encoded_params(self): - """ - Test fetch method with encoded parameters to be re-encoded internally. + """Test fetch method with encoded parameters to be re-encoded inside. HTTPBin returns the args in their urldecoded form, so what we put in should be the same as what we get out. @@ -555,6 +553,7 @@ def test_encoded_params(self): class DataBodyParameterTestCase(HttpbinTestCase): + """Test data and body params of fetch/request methods are equivalent.""" maxDiff = None diff --git a/tests/interwikidata_tests.py b/tests/interwikidata_tests.py index 45ae584920..51b0f1cd91 100755 --- a/tests/interwikidata_tests.py +++ b/tests/interwikidata_tests.py @@ -13,7 +13,7 @@ import pywikibot from pywikibot import Link from scripts import interwikidata -from tests.aspects import SiteAttributeTestCase +from tests.aspects import AlteredDefaultSiteTestCase, SiteAttributeTestCase from tests.utils import empty_sites @@ -31,10 +31,10 @@ def create_item(self): def try_to_add(self): """Prevent adding sitelinks to items.""" - return None + return -class TestInterwikidataBot(SiteAttributeTestCase): +class TestInterwikidataBot(AlteredDefaultSiteTestCase, SiteAttributeTestCase): """Test Interwikidata.""" @@ -53,10 +53,13 @@ class TestInterwikidataBot(SiteAttributeTestCase): }, } - def test_main(self): + def test_main(self, key): """Test main function interwikidata.py.""" - # The default site is used here - if pywikibot.Site().has_data_repository: + site = self.get_site(key) + pywikibot.config.family = site.family + pywikibot.config.mylang = site.code + + if site.has_data_repository: with empty_sites(): # The main function return None. self.assertIsNone(interwikidata.main()) diff --git a/tests/link_tests.py b/tests/link_tests.py index 65a26ee17d..256347efc3 100755 --- a/tests/link_tests.py +++ b/tests/link_tests.py @@ -59,8 +59,7 @@ def test(self): class TestLink(DefaultDrySiteTestCase): - """ - Test parsing links with DrySite. + """Test parsing links with DrySite. The DrySite is using the builtin namespaces which behaviour is controlled in this repository so namespace aware tests do work, even when the actual @@ -164,7 +163,7 @@ def generate_has_no_title_exc_regex(text): # A link is invalid if their (non-)talk page would be in another # namespace than the link's "other" namespace (['Talk:File:Example.svg'], - r'The \(non-\)talk page of (u|)\'Talk:File:Example.svg\'' + r"The \(non-\)talk page of 'Talk:File:Example.svg'" r' is a valid title in another namespace.'), (['.', '..', './Sandbox', '../Sandbox', 'Foo/./Sandbox', diff --git a/tests/logentries_tests.py b/tests/logentries_tests.py index 2e9247423d..2aa90ce959 100755 --- a/tests/logentries_tests.py +++ b/tests/logentries_tests.py @@ -26,8 +26,7 @@ class TestLogentriesBase(TestCase): - """ - Base class for log entry tests. + """Base class for log entry tests. It uses the German Wikipedia for a current representation of the log entries and the test Wikipedia for the future representation. diff --git a/tests/login_tests.py b/tests/login_tests.py index f27d0e3a52..0e95d182e9 100755 --- a/tests/login_tests.py +++ b/tests/login_tests.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Tests for LoginManager classes. +"""Tests for LoginManager classes. e.g. used to test password-file based login. """ @@ -21,12 +20,14 @@ class FakeFamily: + """Mock.""" name = '~FakeFamily' class FakeSite: + """Mock.""" code = '~FakeCode' @@ -37,6 +38,7 @@ class FakeSite: class FakeConfig: + """Mock.""" usernames = defaultdict(dict) @@ -46,6 +48,7 @@ class FakeConfig: @mock.patch('pywikibot.Site', FakeSite) @mock.patch('pywikibot.login.config', FakeConfig) class TestOfflineLoginManager(DefaultDrySiteTestCase): + """Test offline operation of login.LoginManager.""" dry = True @@ -82,6 +85,7 @@ def test_star_family(self): @mock.patch('pywikibot.Site', FakeSite) class TestPasswordFile(DefaultDrySiteTestCase): + """Test parsing password files.""" def patch(self, name): diff --git a/tests/mediawikiversion_tests.py b/tests/mediawikiversion_tests.py index f5c2f15870..f85e440097 100755 --- a/tests/mediawikiversion_tests.py +++ b/tests/mediawikiversion_tests.py @@ -97,7 +97,7 @@ def test_invalid_versions(self): MediaWikiVersion('1.missing') with self.assertRaisesRegex( AssertionError, - 'Found \"wmf\" in \"wmf-1\"'): + 'Found "wmf" in "wmf-1"'): MediaWikiVersion('1.33wmf-1') def test_generator(self): diff --git a/tests/noreferences_tests.py b/tests/noreferences_tests.py index d64d2d6125..80ed3b6cb8 100755 --- a/tests/noreferences_tests.py +++ b/tests/noreferences_tests.py @@ -13,6 +13,7 @@ class TestAddingReferences(TestCase): + """Test adding references to section.""" family = 'wikipedia' diff --git a/tests/oauth_tests.py b/tests/oauth_tests.py index a39c528832..bbdb8e3b05 100755 --- a/tests/oauth_tests.py +++ b/tests/oauth_tests.py @@ -92,7 +92,7 @@ def test_edit(self): self.assertIn(ts, t) -class TestOauthLoginManger(DefaultSiteTestCase, OAuthSiteTestCase): +class TestOauthLoginManager(DefaultSiteTestCase, OAuthSiteTestCase): """Test OAuth login manager.""" diff --git a/tests/page_tests.py b/tests/page_tests.py index 716bb8c13e..30bb9b1e62 100755 --- a/tests/page_tests.py +++ b/tests/page_tests.py @@ -597,8 +597,7 @@ def test_depth(self): self.assertEqual(page_d3.depth, 3) def test_page_image(self): - """ - Test ``Page.page_image`` function. + """Test ``Page.page_image`` function. Since we are not sure what the wiki will return, we mainly test types """ @@ -620,6 +619,31 @@ def test_page_image(self): mainpage.page_image() +class TestPageCategories(TestCase): + + """Categories tests class.""" + + family = 'wikipedia' + code = 'en' + + def testCategories(self): + """Test BasePage.categories() with sort keys.""" + mainhelp = pywikibot.Page(self.site, 'Help:Contents') + cat_help = pywikibot.Category(self.site, 'Category:Help') + + for with_sort_key in (False, True): + with self.subTest(with_sort_key=with_sort_key): + cats = list(mainhelp.categories(with_sort_key=with_sort_key)) + self.assertLength(cats, 4) + self.assertIn(cat_help, cats) + for p in cats: + self.assertIsInstance(p, pywikibot.Category) + if with_sort_key: + self.assertEqual(p.sortKey, 'Contents') + else: + self.assertIsNone(p.sortKey) + + class TestPageCoordinates(TestCase): """Test Page Object using German Wikipedia.""" @@ -943,8 +967,7 @@ def test_revisions_time_interval_true(self): class TestPageRedirects(TestCase): - """ - Test redirects. + """Test redirects. This is using the pages 'User:Legoktm/R1', 'User:Legoktm/R2' and 'User:Legoktm/R3' on the English Wikipedia. 'R1' is redirecting to 'R2', @@ -1237,6 +1260,7 @@ def test_invalid_entities(self): class TestPermalink(TestCase): + """Test that permalink links are correct.""" family = 'wikipedia' @@ -1261,6 +1285,7 @@ def test_permalink(self): class TestShortLink(TestCase): + """Test that short link management is correct.""" login = True diff --git a/tests/pagegenerators_tests.py b/tests/pagegenerators_tests.py index 4e4205383c..c7cee26e72 100755 --- a/tests/pagegenerators_tests.py +++ b/tests/pagegenerators_tests.py @@ -546,8 +546,10 @@ def _run_test(self, start_month=1, end_month=12, year=2000): expected = [] for month in range(start_month, end_month + 1): - for day in range(1, calendar.monthrange(year, month)[1] + 1): - expected.append(date.format_date(month, day, self.site)) + expected += [ + date.format_date(month, day, self.site) + for day in range(1, calendar.monthrange(year, month)[1] + 1) + ] self.assertPageTitlesEqual(gen2, expected) @@ -815,8 +817,7 @@ class TestItemClaimFilterPageGenerator(WikidataTestCase): """Test item claim filter page generator generator.""" def _simple_claim_test(self, prop, claim, qualifiers, valid, negate=False): - """ - Test given claim on sample (India) page. + """Test given claim on sample (India) page. :param prop: the property to check :param claim: the claim the property should contain @@ -860,8 +861,7 @@ def test_invalid_qualifiers(self): False) def test_nonexisting_qualifiers(self): - """ - Test ItemClaimFilterPageGenerator on sample page. + """Test ItemClaimFilterPageGenerator on sample page. The item does not have the searched qualifiers. """ diff --git a/tests/proofreadpage_tests.py b/tests/proofreadpage_tests.py index c6811d81be..063b289ff9 100755 --- a/tests/proofreadpage_tests.py +++ b/tests/proofreadpage_tests.py @@ -29,6 +29,7 @@ class TestPagesTagParser(TestCase): + """Test TagAttr class.""" net = False diff --git a/tests/pwb_tests.py b/tests/pwb_tests.py index 130ee4a927..5bfbd952e9 100755 --- a/tests/pwb_tests.py +++ b/tests/pwb_tests.py @@ -48,8 +48,7 @@ def _do_check(self, name): return (direct, vpwb) def test_env(self): - """ - Test external environment of pywikibot. + """Test external environment of pywikibot. Make sure the environment is not contaminated, and is the same as the environment we get when directly running a script. @@ -57,8 +56,7 @@ def test_env(self): self._do_check('print_env') def test_locals(self): - """ - Test internal environment of pywikibot. + """Test internal environment of pywikibot. Make sure the environment is not contaminated, and is the same as the environment we get when directly running a script. diff --git a/tests/reflinks_tests.py b/tests/reflinks_tests.py index ed87e5ed05..4c5553369b 100755 --- a/tests/reflinks_tests.py +++ b/tests/reflinks_tests.py @@ -83,8 +83,7 @@ def test_start_variants(self): class TestReferencesBotConstructor(ScriptMainTestCase): - """ - Test reflinks with run() removed. + """Test reflinks with run() removed. These tests can't verify the order of the pages in the XML as the constructor is given a preloading generator. diff --git a/tests/replacebot_tests.py b/tests/replacebot_tests.py index f7abe982c8..193c952af7 100755 --- a/tests/replacebot_tests.py +++ b/tests/replacebot_tests.py @@ -303,6 +303,33 @@ def test_fix_callable(self): '"no-msg-callable" (all replacements)', ], pywikibot.bot.ui.pop_output()) + def test_pairs_file(self): + """Test handle_pairsfile.""" + result = replace.handle_pairsfile('non existing file') + self.assertIsNone(result) + + msg = pywikibot.bot.ui.pop_output()[0] + self.assertIn('No such file or directory:', msg) + self.assertIn('non existing file', msg) + + result = replace.handle_pairsfile('tests/data/pagelist-lines.txt') + self.assertIsNone(result) + self.assertIn('pagelist-lines.txt contains an incomplete pattern ' + "replacement pair:\n['file', 'bracket', ", + pywikibot.bot.ui.pop_output()[0]) + + # check file with and without BOM + for variant in ('', ' BOM'): + result = replace.handle_pairsfile( + f'tests/data/pairsfile{variant}.txt') + self.assertIsEmpty(pywikibot.bot.ui.pop_output()) + self.assertEqual(result, [ + 'Category:Notice Board Quests', + 'Категория:Задания с Доски объявлений', + 'Windbluff Tower Key', + 'Ключ от Крепости Ветров' + ]) + if __name__ == '__main__': with suppress(SystemExit): diff --git a/tests/script_tests.py b/tests/script_tests.py index 7bd674ea1c..6a420a2034 100755 --- a/tests/script_tests.py +++ b/tests/script_tests.py @@ -368,6 +368,7 @@ class TestScriptSimulate(DefaultSiteTestCase, PwbTestCase, 'category_redirect', 'claimit', 'clean_sandbox', + 'commons_information', # T379455 'coordinate_import', 'delinker', 'disambredir', @@ -446,6 +447,8 @@ class TestScriptGenerator(DefaultSiteTestCase, PwbTestCase, _allowed_failures = { 'basic', 'delete', # T368859 + 'fixing_redirects', # T379455 + 'illustrate_wikidata', # T379455 'imagetransfer', # T368859 'newitem', 'nowcommons', diff --git a/tests/site_detect_tests.py b/tests/site_detect_tests.py index ea9852605b..3c18a1a6bb 100755 --- a/tests/site_detect_tests.py +++ b/tests/site_detect_tests.py @@ -29,8 +29,7 @@ class SiteDetectionTestCase(TestCase): net = True def assertSite(self, url: str): - """ - Assert a MediaWiki site can be loaded from the url. + """Assert a MediaWiki site can be loaded from the url. :param url: Url of tested site :raises AssertionError: Site under url is not MediaWiki powered @@ -171,10 +170,9 @@ class PrivateWikiTestCase(PatchingTestCase): # the user-supplied URL. We need to return enough data for # site_detect.WikiHTMLPageParser to determine the server # version and the API URL. - WEBPATH: ''.join(( - '<meta name="generator" content="', _generator, - '"/>\n<link rel="EditURI" type="application/rsd+xml" ' - 'href="', _apiurl, '?action=rsd"/>')), + WEBPATH: f'<meta name="generator" content="{_generator}"/>\n' + f'<link rel="EditURI" type="application/rsd+xml" ' + f'href="{_apiurl}?action=rsd"/>', APIPATH: '{"error":{"code":"readapidenied"}}', } diff --git a/tests/site_generators_tests.py b/tests/site_generators_tests.py index 96550295b2..3f17ffc999 100755 --- a/tests/site_generators_tests.py +++ b/tests/site_generators_tests.py @@ -38,6 +38,7 @@ class TestSiteGenerators(DefaultSiteTestCase): + """Test cases for Site methods.""" cached = True @@ -709,6 +710,7 @@ def test_unconnected(self): class TestSiteGeneratorsUsers(DefaultSiteTestCase): + """Test cases for Site methods with users.""" cached = True diff --git a/tests/site_login_logout_tests.py b/tests/site_login_logout_tests.py index c4805c8fd1..beb1770402 100755 --- a/tests/site_login_logout_tests.py +++ b/tests/site_login_logout_tests.py @@ -58,6 +58,7 @@ def test_login_logout(self): class TestClearCookies(TestCase): + """Test cookies are cleared after logout.""" login = True diff --git a/tests/site_tests.py b/tests/site_tests.py index 83f0c7de2e..90301bfd48 100755 --- a/tests/site_tests.py +++ b/tests/site_tests.py @@ -104,12 +104,12 @@ def test_constructors(self): pywikibot.site.APISite.fromDBName(dbname, site), pywikibot.Site(sitename)) - def test_language_methods(self): - """Test cases for languages() and related methods.""" + def test_codes_property(self): + """Test cases for codes property and related methods.""" mysite = self.get_site() - langs = mysite.languages() - self.assertIsInstance(langs, list) - self.assertIn(mysite.code, langs) + codes = mysite.codes + self.assertIsInstance(codes, set) + self.assertIn(mysite.code, codes) self.assertIsInstance(mysite.obsolete, bool) ipf = mysite.interwiki_putfirst() if ipf: # no languages use this anymore, keep it for foreign families @@ -118,7 +118,7 @@ def test_language_methods(self): self.assertIsNone(ipf) for item in mysite.validLanguageLinks(): - self.assertIn(item, langs) + self.assertIn(item, codes) self.assertIsNone(self.site.namespaces.lookup_name(item)) def test_namespace_methods(self): @@ -196,7 +196,7 @@ def test_messages(self): self.assertLength(mysite.mediawiki_messages(months, lang1), 12) self.assertLength(mysite.mediawiki_messages(months, lang2), 12) familyname = mysite.family.name - if lang1 != lang2 and lang1 != familyname and lang2 != familyname: + if lang1 not in (lang2, familyname) and lang2 != familyname: self.assertNotEqual(mysite.mediawiki_messages(months, lang1), mysite.mediawiki_messages(months, lang2)) @@ -294,6 +294,7 @@ def test_ratelimit(self): class TestLockingPage(DefaultSiteTestCase): + """Test cases for lock/unlock a page within threads.""" cached = True @@ -1015,7 +1016,8 @@ def test_has_linktrail(self): small_wikis = self.site.family.languages_by_size[-size:] great_wikis = self.site.family.languages_by_size[:-size] great_wikis = random.sample(great_wikis, size) - for code in sorted(small_wikis + great_wikis): + # Also test for 'hr' which failed due to T378787 + for code in {'hr', *small_wikis, *great_wikis}: site = pywikibot.Site(code, self.family) with self.subTest(site=site): self.assertIsInstance(site.linktrail(), str) diff --git a/tests/sparql_tests.py b/tests/sparql_tests.py index 01cc2434f3..243ad7b97e 100755 --- a/tests/sparql_tests.py +++ b/tests/sparql_tests.py @@ -13,7 +13,7 @@ from unittest.mock import patch import pywikibot -import pywikibot.data.sparql as sparql +from pywikibot.data import sparql from pywikibot.exceptions import NoUsernameError from tests.aspects import TestCase, WikidataTestCase from tests.utils import skipping @@ -88,6 +88,7 @@ class Container: + """Simple test container for return values.""" def __init__(self, value): @@ -100,6 +101,7 @@ def json(self): class TestSparql(WikidataTestCase): + """Test SPARQL queries.""" @patch.object(sparql.http, 'fetch') @@ -184,6 +186,7 @@ def testQueryAsk(self, mock_method): class TestCommonsQueryService(TestCase): + """Test Commons Query Service auth.""" family = 'commons' @@ -211,9 +214,11 @@ def testLoginAndOauthPermisson(self): class Shared: + """Shared test placeholder.""" class SparqlNodeTests(TestCase): + """Tests encoding issues.""" net = False @@ -233,6 +238,7 @@ def test__str__returnsStringType(self): class LiteralTests(Shared.SparqlNodeTests): + """Tests for sparql.Literal.""" net = False @@ -241,6 +247,7 @@ class LiteralTests(Shared.SparqlNodeTests): class BnodeTests(Shared.SparqlNodeTests): + """Tests for sparql.Bnode.""" net = False @@ -248,6 +255,7 @@ class BnodeTests(Shared.SparqlNodeTests): class URITests(Shared.SparqlNodeTests): + """Tests for sparql.URI.""" net = False diff --git a/tests/superset_tests.py b/tests/superset_tests.py index 744b430280..6712c856ca 100755 --- a/tests/superset_tests.py +++ b/tests/superset_tests.py @@ -21,6 +21,7 @@ class TestSupersetWithoutAuth(TestCase): + """Test Superset without auth.""" family = 'meta' @@ -46,6 +47,7 @@ def test_init(self): class TestSupersetWithAuth(TestCase): + """Test Superset with auth.""" login = True diff --git a/tests/textlib_tests.py b/tests/textlib_tests.py index 5acd7568a8..bdd979a1be 100755 --- a/tests/textlib_tests.py +++ b/tests/textlib_tests.py @@ -189,8 +189,7 @@ def test_add_text(self): class TestCategoryRearrangement(DefaultDrySiteTestCase): - """ - Ensure that sorting keys are not being lost. + """Ensure that sorting keys are not being lost. Tests .getCategoryLinks() and .replaceCategoryLinks(), with both a newline and an empty string as separators. @@ -966,6 +965,7 @@ def test_replace_interwiki_links(self): class TestReplaceLinksNonDry(TestCase): + """Test the replace_links function in textlib non-dry.""" family = 'wikipedia' diff --git a/tests/time_tests.py b/tests/time_tests.py index 3deaf37432..c317c7c0d5 100755 --- a/tests/time_tests.py +++ b/tests/time_tests.py @@ -119,7 +119,7 @@ def test_set_from_string_posix(self): def test_set_from_string_invalid(self): """Test failure creating instance from invalid string.""" for timestr, _posix in self.test_results['INVALID']: - regex = "time data \'[^\']*?\' does not match" + regex = "time data '[^']*?' does not match" with self.subTest(timestr), \ self.assertRaisesRegex(ValueError, regex): Timestamp.set_timestamp(timestr) diff --git a/tests/tools_deprecate_tests.py b/tests/tools_deprecate_tests.py index 335f3b87ea..76183959db 100755 --- a/tests/tools_deprecate_tests.py +++ b/tests/tools_deprecate_tests.py @@ -122,8 +122,7 @@ def deprecated_func_docstring_arg(foo=None): @deprecated def deprecated_func_docstring_arg2(foo=None): - """ - DEPRECATED. + """DEPRECATED. :param foo: Foo. DEPRECATED. """ @@ -145,7 +144,7 @@ def deprecated_func_arg3(foo=None): @remove_last_args(['foo', 'bar']) def deprecated_all(): """Test remove_last_args with all args removed.""" - return None + return @remove_last_args(['bar']) @@ -233,7 +232,7 @@ def deprecated_instance_method_and_arg2(self, foo): @remove_last_args(['foo', 'bar']) def deprecated_all(self): """Deprecating positional parameters.""" - return None + return @remove_last_args(['bar']) def deprecated_all2(self, foo): @@ -327,7 +326,7 @@ def test_deprecated_function_multiline_docstring(self): Python 3.13 strips the doc string, see https://docs.python.org/3.13/whatsnew/3.13.html#other-language-changes """ - doc = '\n DEPRECATED.\n\n :param foo: Foo. DEPRECATED.\n ' + doc = 'DEPRECATED.\n\n :param foo: Foo. DEPRECATED.\n ' if PYTHON_VERSION < (3, 13): self.assertEqual(deprecated_func_docstring_arg2.__doc__, doc) else: diff --git a/tests/ui_tests.py b/tests/ui_tests.py index d688b0c1b3..dab9f0236c 100755 --- a/tests/ui_tests.py +++ b/tests/ui_tests.py @@ -495,8 +495,7 @@ class FakeUnixTest(FakeUIColorizedTestBase, FakeUITest): class FakeWin32Test(FakeUIColorizedTestBase, FakeUITest): - """ - Test case to allow doing colorized Win32 tests in any environment. + """Test case to allow doing colorized Win32 tests in any environment. This only patches the ctypes import in the terminal_interface_win32 module. As the Win32CtypesUI is using the std-streams from another diff --git a/tests/upload_tests.py b/tests/upload_tests.py index 66cd0e8f4e..8e90d05649 100755 --- a/tests/upload_tests.py +++ b/tests/upload_tests.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Site upload test. +"""Site upload test. These tests write to the wiki. """ diff --git a/tests/uploadbot_tests.py b/tests/uploadbot_tests.py index 7bffec2a18..4c7e943883 100755 --- a/tests/uploadbot_tests.py +++ b/tests/uploadbot_tests.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -UploadRobot test. +"""UploadRobot test. These tests write to the wiki. """ @@ -40,8 +39,9 @@ def test_png_list(self): """Test uploading a list of pngs using upload.py.""" image_list = [] for directory_info in os.walk(join_images_path()): - for dir_file in directory_info[2]: - image_list.append(os.path.join(directory_info[0], dir_file)) + image_list += [os.path.join(directory_info[0], dir_file) + for dir_file in directory_info[2]] + bot = UploadRobot(url=image_list, target_site=self.get_site(), **self.params) bot.run() diff --git a/tests/utils.py b/tests/utils.py index 25b4547914..89b92bb43c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -23,6 +23,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 @@ -31,8 +32,7 @@ def expected_failure_if(expect): - """ - Unit test decorator to expect failure under conditions. + """Unit test decorator to expect failure under conditions. :param expect: Flag to check if failure is expected :type expect: bool @@ -75,8 +75,7 @@ def entered_loop(iterable): class WarningSourceSkipContextManager(warnings.catch_warnings): - """ - Warning context manager that adjusts source of warning. + """Warning context manager that adjusts source of warning. The source of the warning will be moved further down the stack to skip a list of objects that have been monkey @@ -84,8 +83,7 @@ class WarningSourceSkipContextManager(warnings.catch_warnings): """ def __init__(self, skip_list): - """ - Initializer. + """Initializer. :param skip_list: List of objects to be skipped. The source of any warning that matches the skip_list won't be adjusted. @@ -96,8 +94,7 @@ def __init__(self, skip_list): @property def skip_list(self): - """ - Return list of filename and line ranges to skip. + """Return list of filename and line ranges to skip. :rtype: list of (obj, str, int, int) """ @@ -105,8 +102,7 @@ def skip_list(self): @skip_list.setter def skip_list(self, value): - """ - Set list of objects to be skipped. + """Set list of objects to be skipped. :param value: List of objects to be skipped :type value: list of object or (obj, str, int, int) @@ -173,8 +169,7 @@ def detailed_show_warning(*args, **kwargs): class AssertAPIErrorContextManager: - """ - Context manager to assert certain APIError exceptions. + """Context manager to assert certain APIError exceptions. This is build similar to the :py:obj:`unittest.TestCase.assertError` implementation which creates a context manager. It then calls @@ -303,7 +298,7 @@ def is_cached(self, key: str) -> bool: def is_recognised(self, key): """Return None.""" - return None + return def get_requested_time(self, key): """Return False.""" @@ -479,6 +474,9 @@ def execute(command: list[str], *, data_in=None, timeout=None): :param command: executable to run and arguments to use """ + if PYTHON_VERSION < (3, 8): + command.insert(1, '-W ignore::FutureWarning:pywikibot:97') + env = os.environ.copy() # Prevent output by test package; e.g. 'max_retries reduced from x to y' diff --git a/tests/wbtypes_tests.py b/tests/wbtypes_tests.py new file mode 100755 index 0000000000..e3cf190b51 --- /dev/null +++ b/tests/wbtypes_tests.py @@ -0,0 +1,1035 @@ +#!/usr/bin/env python3 +"""Tests for the Wikidata parts of the page module.""" +# +# (C) Pywikibot team, 2008-2024 +# +# Distributed under the terms of the MIT license. +# +from __future__ import annotations + +import datetime +import operator +import unittest +from contextlib import suppress +from decimal import Decimal + +import pywikibot +from pywikibot.page import ItemPage, Page +from pywikibot.tools import MediaWikiVersion +from tests.aspects import WikidataTestCase + + +class WbRepresentationTestCase(WikidataTestCase): + + """Test methods inherited or extended from _WbRepresentation.""" + + def _test_hashable(self, representation): + """Test that the representation is hashable.""" + list_of_dupes = [representation, representation] + self.assertLength(set(list_of_dupes), 1) + + +class TestWikibaseCoordinate(WbRepresentationTestCase): + + """Test Wikibase Coordinate data type.""" + + dry = True + + def test_Coordinate_WbRepresentation_methods(self): + """Test inherited or extended methods from _WbRepresentation.""" + repo = self.get_repo() + coord = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0, + globe='moon') + self._test_hashable(coord) + + def test_Coordinate_dim(self): + """Test Coordinate dimension.""" + repo = self.get_repo() + x = pywikibot.Coordinate(site=repo, lat=12.0, lon=13.0, precision=5.0) + self.assertEqual(x.precisionToDim(), 544434) + self.assertIsInstance(x.precisionToDim(), int) + y = pywikibot.Coordinate(site=repo, lat=12.0, lon=13.0, dim=54444) + self.assertEqual(y.precision, 0.500005084017101) + self.assertIsInstance(y.precision, float) + z = pywikibot.Coordinate(site=repo, lat=12.0, lon=13.0) + regex = r'^No values set for dim or precision$' + with self.assertRaisesRegex(ValueError, regex): + z.precisionToDim() + + def test_Coordinate_plain_globe(self): + """Test setting Coordinate globe from a plain-text value.""" + repo = self.get_repo() + coord = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0, + globe='moon') + self.assertEqual(coord.toWikibase(), + {'latitude': 12.0, 'longitude': 13.0, + 'altitude': None, 'precision': 0, + 'globe': 'http://www.wikidata.org/entity/Q405'}) + + def test_Coordinate_entity_uri_globe(self): + """Test setting Coordinate globe from an entity uri.""" + repo = self.get_repo() + coord = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0, + globe_item='http://www.wikidata.org/entity/Q123') + self.assertEqual(coord.toWikibase(), + {'latitude': 12.0, 'longitude': 13.0, + 'altitude': None, 'precision': 0, + 'globe': 'http://www.wikidata.org/entity/Q123'}) + + +class TestWikibaseCoordinateNonDry(WbRepresentationTestCase): + + """Test Wikibase Coordinate data type (non-dry). + + These can be moved to TestWikibaseCoordinate once DrySite has been bumped + to the appropriate version. + """ + + def test_Coordinate_item_globe(self): + """Test setting Coordinate globe from an ItemPage.""" + repo = self.get_repo() + coord = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0, + globe_item=ItemPage(repo, 'Q123')) + self.assertEqual(coord.toWikibase(), + {'latitude': 12.0, 'longitude': 13.0, + 'altitude': None, 'precision': 0, + 'globe': 'http://www.wikidata.org/entity/Q123'}) + + def test_Coordinate_get_globe_item_from_uri(self): + """Test getting globe item from Coordinate with entity uri globe.""" + repo = self.get_repo() + q = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0, + globe_item='http://www.wikidata.org/entity/Q123') + self.assertEqual(q.get_globe_item(), ItemPage(repo, 'Q123')) + + def test_Coordinate_get_globe_item_from_itempage(self): + """Test getting globe item from Coordinate with ItemPage globe.""" + repo = self.get_repo() + globe = ItemPage(repo, 'Q123') + q = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0, globe_item=globe) + self.assertEqual(q.get_globe_item(), ItemPage(repo, 'Q123')) + + def test_Coordinate_get_globe_item_from_plain_globe(self): + """Test getting globe item from Coordinate with plain text globe.""" + repo = self.get_repo() + q = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0, globe='moon') + self.assertEqual(q.get_globe_item(), ItemPage(repo, 'Q405')) + + def test_Coordinate_get_globe_item_provide_repo(self): + """Test getting globe item from Coordinate, providing repo.""" + repo = self.get_repo() + q = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0, + globe_item='http://www.wikidata.org/entity/Q123') + self.assertEqual(q.get_globe_item(repo), ItemPage(repo, 'Q123')) + + def test_Coordinate_get_globe_item_different_repo(self): + """Test getting globe item in different repo from Coordinate.""" + repo = self.get_repo() + test_repo = pywikibot.Site('test', 'wikidata') + q = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0, + globe_item='http://test.wikidata.org/entity/Q123') + self.assertEqual(q.get_globe_item(test_repo), + ItemPage(test_repo, 'Q123')) + + def test_Coordinate_equality(self): + """Test Coordinate equality with different globe representations.""" + repo = self.get_repo() + a = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0.1, + globe='moon') + b = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0.1, + globe_item='http://www.wikidata.org/entity/Q405') + c = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0.1, + globe_item=ItemPage(repo, 'Q405')) + d = pywikibot.Coordinate( + site=repo, lat=12.0, lon=13.0, precision=0.1, + globe_item='http://test.wikidata.org/entity/Q405') + self.assertEqual(a, b) + self.assertEqual(b, c) + self.assertEqual(c, a) + self.assertNotEqual(a, d) + self.assertNotEqual(b, d) + self.assertNotEqual(c, d) + + +class TestWbTime(WbRepresentationTestCase): + + """Test Wikibase WbTime data type.""" + + dry = True + + def test_WbTime_WbRepresentation_methods(self): + """Test inherited or extended methods from _WbRepresentation.""" + repo = self.get_repo() + t = pywikibot.WbTime(site=repo, year=2010, month=0, day=0, hour=12, + minute=43) + self._test_hashable(t) + + def test_WbTime_timestr(self): + """Test timestr functions of WbTime.""" + repo = self.get_repo() + t = pywikibot.WbTime(site=repo, year=2010, month=0, day=0, hour=12, + minute=43) + self.assertEqual(t.toTimestr(), '+00000002010-00-00T12:43:00Z') + self.assertEqual(t.toTimestr(force_iso=True), '+2010-01-01T12:43:00Z') + + t = pywikibot.WbTime(site=repo, year=2010, hour=12, minute=43) + self.assertEqual(t.toTimestr(), '+00000002010-01-01T12:43:00Z') + self.assertEqual(t.toTimestr(force_iso=True), '+2010-01-01T12:43:00Z') + + t = pywikibot.WbTime(site=repo, year=-2010, hour=12, minute=43) + self.assertEqual(t.toTimestr(), '-00000002010-01-01T12:43:00Z') + self.assertEqual(t.toTimestr(force_iso=True), '-2010-01-01T12:43:00Z') + + def test_WbTime_fromTimestr(self): + """Test WbTime creation from UTC date/time string.""" + repo = self.get_repo() + t = pywikibot.WbTime.fromTimestr('+00000002010-01-01T12:43:00Z', + site=repo) + self.assertEqual(t, pywikibot.WbTime(site=repo, year=2010, hour=12, + minute=43, precision=14)) + + def test_WbTime_zero_month(self): + """Test WbTime creation from date/time string with zero month.""" + # ensures we support formats in T123888 / T107870 + repo = self.get_repo() + t = pywikibot.WbTime.fromTimestr('+00000002010-00-00T12:43:00Z', + site=repo) + self.assertEqual(t, pywikibot.WbTime(site=repo, year=2010, month=0, + day=0, hour=12, minute=43, + precision=14)) + + def test_WbTime_skip_params_precision(self): + """Test skipping units (such as day, month) when creating WbTimes.""" + repo = self.get_repo() + t = pywikibot.WbTime(year=2020, day=2, site=repo) + self.assertEqual(t, pywikibot.WbTime(year=2020, month=1, day=2, + site=repo)) + self.assertEqual(t.precision, pywikibot.WbTime.PRECISION['day']) + t2 = pywikibot.WbTime(year=2020, hour=5, site=repo) + self.assertEqual(t2, pywikibot.WbTime(year=2020, month=1, day=1, + hour=5, site=repo)) + self.assertEqual(t2.precision, pywikibot.WbTime.PRECISION['hour']) + t3 = pywikibot.WbTime(year=2020, minute=5, site=repo) + self.assertEqual(t3, pywikibot.WbTime(year=2020, month=1, day=1, + hour=0, minute=5, site=repo)) + self.assertEqual(t3.precision, pywikibot.WbTime.PRECISION['minute']) + t4 = pywikibot.WbTime(year=2020, second=5, site=repo) + self.assertEqual(t4, pywikibot.WbTime(year=2020, month=1, day=1, + hour=0, minute=0, second=5, + site=repo)) + self.assertEqual(t4.precision, pywikibot.WbTime.PRECISION['second']) + t5 = pywikibot.WbTime(year=2020, month=2, hour=5, site=repo) + self.assertEqual(t5, pywikibot.WbTime(year=2020, month=2, day=1, + hour=5, site=repo)) + self.assertEqual(t5.precision, pywikibot.WbTime.PRECISION['hour']) + t6 = pywikibot.WbTime(year=2020, month=2, minute=5, site=repo) + self.assertEqual(t6, pywikibot.WbTime(year=2020, month=2, day=1, + hour=0, minute=5, site=repo)) + self.assertEqual(t6.precision, pywikibot.WbTime.PRECISION['minute']) + t7 = pywikibot.WbTime(year=2020, month=2, second=5, site=repo) + self.assertEqual(t7, pywikibot.WbTime(year=2020, month=2, day=1, + hour=0, minute=0, second=5, + site=repo)) + self.assertEqual(t7.precision, pywikibot.WbTime.PRECISION['second']) + t8 = pywikibot.WbTime(year=2020, day=2, hour=5, site=repo) + self.assertEqual(t8, pywikibot.WbTime(year=2020, month=1, day=2, + hour=5, site=repo)) + self.assertEqual(t8.precision, pywikibot.WbTime.PRECISION['hour']) + t9 = pywikibot.WbTime(year=2020, month=3, day=2, minute=5, site=repo) + self.assertEqual(t9, pywikibot.WbTime(year=2020, month=3, day=2, + hour=0, minute=5, site=repo)) + self.assertEqual(t9.precision, pywikibot.WbTime.PRECISION['minute']) + + def test_WbTime_normalization(self): + """Test WbTime normalization.""" + repo = self.get_repo() + # flake8 is being annoying, so to reduce line length, I'll make + # some aliases here + decade = pywikibot.WbTime.PRECISION['decade'] + century = pywikibot.WbTime.PRECISION['century'] + millenia = pywikibot.WbTime.PRECISION['millenia'] + t = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12) + t2 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, + precision=pywikibot.WbTime.PRECISION['second']) + t3 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, + precision=pywikibot.WbTime.PRECISION['minute']) + t4 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, + precision=pywikibot.WbTime.PRECISION['hour']) + t5 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, + precision=pywikibot.WbTime.PRECISION['day']) + t6 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, + precision=pywikibot.WbTime.PRECISION['month']) + t7 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, + precision=pywikibot.WbTime.PRECISION['year']) + t8 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, + precision=decade) + t9 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, + precision=century) + t10 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, + precision=millenia) + t11 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, timezone=-300, + precision=pywikibot.WbTime.PRECISION['day']) + t12 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, timezone=300, + precision=pywikibot.WbTime.PRECISION['day']) + t13 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=12, timezone=-300, + precision=pywikibot.WbTime.PRECISION['hour']) + self.assertEqual(t.normalize(), t) + self.assertEqual(t2.normalize(), t.normalize()) + self.assertEqual(t3.normalize(), + pywikibot.WbTime(site=repo, year=2010, month=1, + day=1, hour=12, minute=43)) + self.assertEqual(t4.normalize(), + pywikibot.WbTime(site=repo, year=2010, + month=1, day=1, hour=12)) + self.assertEqual(t5.normalize(), + pywikibot.WbTime(site=repo, year=2010, + month=1, day=1)) + self.assertEqual(t6.normalize(), + pywikibot.WbTime(site=repo, year=2010, + month=1)) + self.assertEqual( + t7.normalize(), pywikibot.WbTime(site=repo, year=2010)) + self.assertEqual(t8.normalize(), + pywikibot.WbTime(site=repo, year=2010, + precision=decade)) + self.assertEqual(t9.normalize(), + pywikibot.WbTime(site=repo, year=2100, + precision=century)) + self.assertEqual(t9.normalize(), + pywikibot.WbTime(site=repo, year=2010, + precision=century).normalize()) + self.assertEqual(t10.normalize(), + pywikibot.WbTime(site=repo, year=3000, + precision=millenia)) + self.assertEqual(t10.normalize(), + pywikibot.WbTime(site=repo, year=2010, + precision=millenia).normalize()) + t11_normalized = t11.normalize() + t12_normalized = t12.normalize() + self.assertEqual(t11_normalized.timezone, 0) + self.assertEqual(t12_normalized.timezone, 0) + self.assertNotEqual(t11, t12) + self.assertEqual(t11_normalized, t12_normalized) + self.assertEqual(t13.normalize().timezone, -300) + + def test_WbTime_normalization_very_low_precision(self): + """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'] + year_10000000 = pywikibot.WbTime.PRECISION['10000000'] + year_100000000 = pywikibot.WbTime.PRECISION['100000000'] + year_1000000000 = pywikibot.WbTime.PRECISION['1000000000'] + t = pywikibot.WbTime(site=repo, year=-3124684989, + precision=year_10000) + t2 = pywikibot.WbTime(site=repo, year=-3124684989, + precision=year_100000) + t3 = pywikibot.WbTime(site=repo, year=-3124684989, + precision=year_1000000) + t4 = pywikibot.WbTime(site=repo, year=-3124684989, + precision=year_10000000) + t5 = pywikibot.WbTime(site=repo, year=-3124684989, + precision=year_100000000) + t6 = pywikibot.WbTime(site=repo, year=-3124684989, + precision=year_1000000000) + self.assertEqual(t.normalize(), + pywikibot.WbTime(site=repo, year=-3124680000, + precision=year_10000)) + self.assertEqual(t2.normalize(), + pywikibot.WbTime(site=repo, year=-3124700000, + precision=year_100000)) + self.assertEqual(t3.normalize(), + pywikibot.WbTime(site=repo, year=-3125000000, + precision=year_1000000)) + self.assertEqual(t4.normalize(), + pywikibot.WbTime(site=repo, year=-3120000000, + precision=year_10000000)) + self.assertEqual(t5.normalize(), + pywikibot.WbTime(site=repo, year=-3100000000, + precision=year_100000000)) + self.assertEqual(t6.normalize(), + pywikibot.WbTime(site=repo, year=-3000000000, + precision=year_1000000000)) + + def test_WbTime_timestamp(self): + """Test timestamp functions of WbTime.""" + repo = self.get_repo() + timestamp = pywikibot.Timestamp.fromISOformat('2010-01-01T12:43:00Z') + t = pywikibot.WbTime(site=repo, year=2010, month=0, day=0, hour=12, + minute=43) + self.assertEqual(t.toTimestamp(), timestamp) + + # Roundtrip fails as Timestamp and WbTime interpret month=0 differently + self.assertNotEqual( + t, pywikibot.WbTime.fromTimestamp(timestamp, site=repo)) + + t = pywikibot.WbTime(site=repo, year=2010, hour=12, minute=43) + self.assertEqual(t.toTimestamp(), timestamp) + + t = pywikibot.WbTime(site=repo, year=-2010, hour=12, minute=43) + regex = r'^You cannot turn BC dates into a Timestamp$' + with self.assertRaisesRegex(ValueError, regex): + t.toTimestamp() + + t = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, + minute=43, second=0) + self.assertEqual(t.toTimestamp(), timestamp) + self.assertEqual( + t, pywikibot.WbTime.fromTimestamp(timestamp, site=repo)) + timezone = datetime.timezone(datetime.timedelta(hours=-5)) + ts = pywikibot.Timestamp(2020, 1, 1, 12, 43, 0, tzinfo=timezone) + t = pywikibot.WbTime.fromTimestamp(ts, site=repo, copy_timezone=True) + self.assertEqual(t.timezone, -5 * 60) + t = pywikibot.WbTime.fromTimestamp(ts, site=repo, copy_timezone=True, + timezone=60) + self.assertEqual(t.timezone, 60) + + ts1 = pywikibot.Timestamp( + year=2022, month=12, day=21, hour=13, + tzinfo=datetime.timezone(datetime.timedelta(hours=-5))) + t1 = pywikibot.WbTime.fromTimestamp(ts1, timezone=-300, site=repo) + self.assertIsNotNone(t1.toTimestamp(timezone_aware=True).tzinfo) + self.assertIsNone(t1.toTimestamp(timezone_aware=False).tzinfo) + self.assertEqual(t1.toTimestamp(timezone_aware=True), ts1) + self.assertNotEqual(t1.toTimestamp(timezone_aware=False), ts1) + + def test_WbTime_errors(self): + """Test WbTime precision errors.""" + repo = self.get_repo() + regex = r'^no year given$' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbTime(site=repo, precision=15) + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbTime(site=repo, precision='invalid_precision') + regex = r'^Invalid precision: "15"$' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbTime(site=repo, year=2020, precision=15) + regex = r'^Invalid precision: "invalid_precision"$' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbTime(site=repo, year=2020, + precision='invalid_precision') + + def test_comparison(self): + """Test WbTime comparison.""" + repo = self.get_repo() + t1 = pywikibot.WbTime(site=repo, year=2010, hour=12, minute=43) + t2 = pywikibot.WbTime(site=repo, year=-2005, hour=16, minute=45) + self.assertEqual(t1.precision, pywikibot.WbTime.PRECISION['minute']) + self.assertEqual(t1, t1) + self.assertGreaterEqual(t1, t1) + self.assertGreaterEqual(t1, t2) + self.assertGreater(t1, t2) + self.assertEqual(t1.year, 2010) + self.assertEqual(t2.year, -2005) + self.assertEqual(t1.month, 1) + self.assertEqual(t2.month, 1) + self.assertEqual(t1.day, 1) + self.assertEqual(t2.day, 1) + self.assertEqual(t1.hour, 12) + self.assertEqual(t2.hour, 16) + self.assertEqual(t1.minute, 43) + self.assertEqual(t2.minute, 45) + self.assertEqual(t1.second, 0) + 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') + self.assertIsInstance(t1.toTimestamp(), pywikibot.Timestamp) + self.assertRaises(ValueError, t2.toTimestamp) + + def test_comparison_types(self): + """Test WbTime comparison with different types.""" + repo = self.get_repo() + 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) + + def test_comparison_timezones(self): + """Test comparisons with timezones.""" + repo = self.get_repo() + ts1 = pywikibot.Timestamp( + year=2022, month=12, day=21, hour=13, + tzinfo=datetime.timezone(datetime.timedelta(hours=-5))) + ts2 = pywikibot.Timestamp( + year=2022, month=12, day=21, hour=17, + tzinfo=datetime.timezone.utc) + self.assertGreater(ts1.timestamp(), ts2.timestamp()) + + t1 = pywikibot.WbTime.fromTimestamp(ts1, timezone=-300, site=repo) + t2 = pywikibot.WbTime.fromTimestamp(ts2, timezone=0, site=repo) + self.assertGreater(t1, t2) + + def test_comparison_timezones_equal(self): + """Test when two WbTime's have equal instants but not the same tz.""" + repo = self.get_repo() + ts1 = pywikibot.Timestamp( + year=2023, month=12, day=21, hour=13, + tzinfo=datetime.timezone(datetime.timedelta(hours=-5))) + ts2 = pywikibot.Timestamp( + year=2023, month=12, day=21, hour=18, + tzinfo=datetime.timezone.utc) + self.assertEqual(ts1.timestamp(), ts2.timestamp()) + + t1 = pywikibot.WbTime.fromTimestamp(ts1, timezone=-300, site=repo) + t2 = pywikibot.WbTime.fromTimestamp(ts2, timezone=0, site=repo) + self.assertGreaterEqual(t1, t2) + self.assertGreaterEqual(t2, t1) + self.assertNotEqual(t1, t2) + self.assertNotEqual(t2, t1) + # Ignore H205: We specifically want to test the operator + self.assertFalse(t1 > t2) # noqa: H205 + self.assertFalse(t2 > t1) # noqa: H205 + self.assertFalse(t1 < t2) # noqa: H205 + self.assertFalse(t2 < t1) # noqa: H205 + + def test_comparison_equal_instant(self): + """Test the equal_instant method.""" + repo = self.get_repo() + + ts1 = pywikibot.Timestamp( + year=2023, month=12, day=21, hour=13, + tzinfo=datetime.timezone(datetime.timedelta(hours=-5))) + ts2 = pywikibot.Timestamp( + year=2023, month=12, day=21, hour=18, + tzinfo=datetime.timezone.utc) + ts3 = pywikibot.Timestamp( + year=2023, month=12, day=21, hour=19, + tzinfo=datetime.timezone(datetime.timedelta(hours=1))) + ts4 = pywikibot.Timestamp( + year=2023, month=12, day=21, hour=13, + tzinfo=datetime.timezone(datetime.timedelta(hours=-6))) + + self.assertEqual(ts1.timestamp(), ts2.timestamp()) + self.assertEqual(ts1.timestamp(), ts3.timestamp()) + self.assertEqual(ts2.timestamp(), ts3.timestamp()) + self.assertNotEqual(ts1.timestamp(), ts4.timestamp()) + self.assertNotEqual(ts2.timestamp(), ts4.timestamp()) + self.assertNotEqual(ts3.timestamp(), ts4.timestamp()) + + t1 = pywikibot.WbTime.fromTimestamp(ts1, timezone=-300, site=repo) + t2 = pywikibot.WbTime.fromTimestamp(ts2, timezone=0, site=repo) + t3 = pywikibot.WbTime.fromTimestamp(ts3, timezone=60, site=repo) + t4 = pywikibot.WbTime.fromTimestamp(ts4, timezone=-360, site=repo) + + self.assertTrue(t1.equal_instant(t2)) + self.assertTrue(t1.equal_instant(t3)) + self.assertTrue(t2.equal_instant(t3)) + self.assertFalse(t1.equal_instant(t4)) + self.assertFalse(t2.equal_instant(t4)) + self.assertFalse(t3.equal_instant(t4)) + + +class TestWbQuantity(WbRepresentationTestCase): + + """Test Wikibase WbQuantity data type.""" + + dry = True + + def test_WbQuantity_WbRepresentation_methods(self): + """Test inherited or extended methods from _WbRepresentation.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount=1234, error=1, site=repo) + self._test_hashable(q) + + def test_WbQuantity_integer(self): + """Test WbQuantity for integer value.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount=1234, error=1, site=repo) + self.assertEqual(q.toWikibase(), + {'amount': '+1234', 'lowerBound': '+1233', + 'upperBound': '+1235', 'unit': '1'}) + q = pywikibot.WbQuantity(amount=5, error=(2, 3), site=repo) + self.assertEqual(q.toWikibase(), + {'amount': '+5', 'lowerBound': '+2', + 'upperBound': '+7', 'unit': '1'}) + q = pywikibot.WbQuantity(amount=0, error=(0, 0), site=repo) + self.assertEqual(q.toWikibase(), + {'amount': '+0', 'lowerBound': '+0', + 'upperBound': '+0', 'unit': '1'}) + q = pywikibot.WbQuantity(amount=-5, error=(2, 3), site=repo) + self.assertEqual(q.toWikibase(), + {'amount': '-5', 'lowerBound': '-8', + 'upperBound': '-3', 'unit': '1'}) + + def test_WbQuantity_float_27(self): + """Test WbQuantity for float value.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount=0.044405586, error=0.0, site=repo) + q_dict = {'amount': '+0.044405586', 'lowerBound': '+0.044405586', + 'upperBound': '+0.044405586', 'unit': '1'} + self.assertEqual(q.toWikibase(), q_dict) + + def test_WbQuantity_scientific(self): + """Test WbQuantity for scientific notation.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount='1.3e-13', error='1e-14', site=repo) + q_dict = {'amount': '+1.3e-13', 'lowerBound': '+1.2e-13', + 'upperBound': '+1.4e-13', 'unit': '1'} + self.assertEqual(q.toWikibase(), q_dict) + + def test_WbQuantity_decimal(self): + """Test WbQuantity for decimal value.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount=Decimal('0.044405586'), + error=Decimal('0.0'), site=repo) + q_dict = {'amount': '+0.044405586', 'lowerBound': '+0.044405586', + 'upperBound': '+0.044405586', 'unit': '1'} + self.assertEqual(q.toWikibase(), q_dict) + + def test_WbQuantity_string(self): + """Test WbQuantity for decimal notation.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount='0.044405586', error='0', site=repo) + q_dict = {'amount': '+0.044405586', 'lowerBound': '+0.044405586', + 'upperBound': '+0.044405586', 'unit': '1'} + self.assertEqual(q.toWikibase(), q_dict) + + def test_WbQuantity_formatting_bound(self): + """Test WbQuantity formatting with bounds.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount='0.044405586', 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')) + self.assertEqual(repr(q), + 'WbQuantity(amount={val}, ' + 'upperBound={val}, lowerBound={val}, ' + 'unit=1)'.format(val='0.044405586')) + + def test_WbQuantity_self_equality(self): + """Test WbQuantity equality.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount='0.044405586', error='0', site=repo) + self.assertEqual(q, q) + + def test_WbQuantity_fromWikibase(self): + """Test WbQuantity.fromWikibase() instantiating.""" + repo = self.get_repo() + q = pywikibot.WbQuantity.fromWikibase({'amount': '+0.0229', + 'lowerBound': '0', + 'upperBound': '1', + 'unit': '1'}, + site=repo) + # note that the bounds are inputted as INT but are returned as FLOAT + self.assertEqual(q.toWikibase(), + {'amount': '+0.0229', 'lowerBound': '+0.0000', + 'upperBound': '+1.0000', 'unit': '1'}) + + def test_WbQuantity_errors(self): + """Test WbQuantity error handling.""" + regex = r'^no amount given$' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbQuantity(amount=None, error=1) + + def test_WbQuantity_entity_unit(self): + """Test WbQuantity with entity uri unit.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount=1234, error=1, site=repo, + unit='http://www.wikidata.org/entity/Q712226') + self.assertEqual(q.toWikibase(), + {'amount': '+1234', 'lowerBound': '+1233', + 'upperBound': '+1235', + 'unit': 'http://www.wikidata.org/entity/Q712226'}) + + def test_WbQuantity_unit_fromWikibase(self): + """Test WbQuantity recognising unit from Wikibase output.""" + repo = self.get_repo() + q = pywikibot.WbQuantity.fromWikibase({ + 'amount': '+1234', 'lowerBound': '+1233', 'upperBound': '+1235', + 'unit': 'http://www.wikidata.org/entity/Q712226'}, + site=repo) + self.assertEqual(q.toWikibase(), + {'amount': '+1234', 'lowerBound': '+1233', + 'upperBound': '+1235', + 'unit': 'http://www.wikidata.org/entity/Q712226'}) + + +class TestWbQuantityNonDry(WbRepresentationTestCase): + + """Test Wikibase WbQuantity data type (non-dry). + + These can be moved to TestWbQuantity once DrySite has been bumped to + the appropriate version. + """ + + def setUp(self): + """Override setup to store repo and it's version.""" + super().setUp() + self.repo = self.get_repo() + self.version = self.repo.mw_version + + def test_WbQuantity_unbound(self): + """Test WbQuantity for value without bounds.""" + if self.version < MediaWikiVersion('1.29.0-wmf.2'): + self.skipTest('Wiki version must be 1.29.0-wmf.2 or newer to ' + 'support unbound uncertainties.') + q = pywikibot.WbQuantity(amount=1234.5, site=self.repo) + self.assertEqual(q.toWikibase(), + {'amount': '+1234.5', 'unit': '1', + 'upperBound': None, 'lowerBound': None}) + + def test_WbQuantity_formatting_unbound(self): + """Test WbQuantity formatting without bounds.""" + if self.version < MediaWikiVersion('1.29.0-wmf.2'): + self.skipTest('Wiki version must be 1.29.0-wmf.2 or newer to ' + 'support unbound uncertainties.') + q = pywikibot.WbQuantity(amount='0.044405586', site=self.repo) + self.assertEqual(str(q), + '{{\n' + ' "amount": "+{val}",\n' + ' "lowerBound": null,\n' + ' "unit": "1",\n' + ' "upperBound": null\n' + '}}'.format(val='0.044405586')) + self.assertEqual(repr(q), + 'WbQuantity(amount={val}, ' + 'upperBound=None, lowerBound=None, ' + 'unit=1)'.format(val='0.044405586')) + + def test_WbQuantity_fromWikibase_unbound(self): + """Test WbQuantity.fromWikibase() instantiating without bounds.""" + if self.version < MediaWikiVersion('1.29.0-wmf.2'): + self.skipTest('Wiki version must be 1.29.0-wmf.2 or newer to ' + 'support unbound uncertainties.') + q = pywikibot.WbQuantity.fromWikibase({'amount': '+0.0229', + 'unit': '1'}, + site=self.repo) + self.assertEqual(q.toWikibase(), + {'amount': '+0.0229', 'lowerBound': None, + 'upperBound': None, 'unit': '1'}) + + def test_WbQuantity_ItemPage_unit(self): + """Test WbQuantity with ItemPage unit.""" + if self.version < MediaWikiVersion('1.28-wmf.23'): + self.skipTest('Wiki version must be 1.28-wmf.23 or newer to ' + 'expose wikibase-conceptbaseuri.') + + q = pywikibot.WbQuantity(amount=1234, error=1, + unit=pywikibot.ItemPage(self.repo, 'Q712226')) + self.assertEqual(q.toWikibase(), + {'amount': '+1234', 'lowerBound': '+1233', + 'upperBound': '+1235', + 'unit': 'http://www.wikidata.org/entity/Q712226'}) + + def test_WbQuantity_equality(self): + """Test WbQuantity equality with different unit representations.""" + if self.version < MediaWikiVersion('1.28-wmf.23'): + self.skipTest('Wiki version must be 1.28-wmf.23 or newer to ' + 'expose wikibase-conceptbaseuri.') + + a = pywikibot.WbQuantity( + amount=1234, error=1, + unit=pywikibot.ItemPage(self.repo, 'Q712226')) + b = pywikibot.WbQuantity( + amount=1234, error=1, + unit='http://www.wikidata.org/entity/Q712226') + c = pywikibot.WbQuantity( + amount=1234, error=1, + unit='http://test.wikidata.org/entity/Q712226') + d = pywikibot.WbQuantity( + amount=1234, error=2, + unit='http://www.wikidata.org/entity/Q712226') + self.assertEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(b, c) + self.assertNotEqual(b, d) + + def test_WbQuantity_get_unit_item(self): + """Test getting unit item from WbQuantity.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount=1234, error=1, site=repo, + unit='http://www.wikidata.org/entity/Q123') + self.assertEqual(q.get_unit_item(), + ItemPage(repo, 'Q123')) + + def test_WbQuantity_get_unit_item_provide_repo(self): + """Test getting unit item from WbQuantity, providing repo.""" + repo = self.get_repo() + q = pywikibot.WbQuantity(amount=1234, error=1, + unit='http://www.wikidata.org/entity/Q123') + self.assertEqual(q.get_unit_item(repo), + ItemPage(repo, 'Q123')) + + def test_WbQuantity_get_unit_item_different_repo(self): + """Test getting unit item in different repo from WbQuantity.""" + repo = self.get_repo() + test_repo = pywikibot.Site('test', 'wikidata') + q = pywikibot.WbQuantity(amount=1234, error=1, site=repo, + unit='http://test.wikidata.org/entity/Q123') + self.assertEqual(q.get_unit_item(test_repo), + ItemPage(test_repo, 'Q123')) + + +class TestWbMonolingualText(WbRepresentationTestCase): + + """Test Wikibase WbMonolingualText data type.""" + + dry = True + + def test_WbMonolingualText_WbRepresentation_methods(self): + """Test inherited or extended methods from _WbRepresentation.""" + q = pywikibot.WbMonolingualText( + text='Test that basics work', language='en') + self._test_hashable(q) + + def test_WbMonolingualText_string(self): + """Test WbMonolingualText string.""" + q = pywikibot.WbMonolingualText(text='Test that basics work', + language='en') + q_dict = {'text': 'Test that basics work', 'language': 'en'} + self.assertEqual(q.toWikibase(), q_dict) + + def test_WbMonolingualText_unicode(self): + """Test WbMonolingualText unicode.""" + q = pywikibot.WbMonolingualText(text='Testa det här', language='sv') + q_dict = {'text': 'Testa det här', 'language': 'sv'} + self.assertEqual(q.toWikibase(), q_dict) + + def test_WbMonolingualText_equality(self): + """Test WbMonolingualText equality.""" + q = pywikibot.WbMonolingualText(text='Thou shall test this!', + language='en-gb') + self.assertEqual(q, q) + + def test_WbMonolingualText_fromWikibase(self): + """Test WbMonolingualText.fromWikibase() instantiating.""" + q = pywikibot.WbMonolingualText.fromWikibase({'text': 'Test this!', + 'language': 'en'}) + self.assertEqual(q.toWikibase(), + {'text': 'Test this!', 'language': 'en'}) + + def test_WbMonolingualText_errors(self): + """Test WbMonolingualText error handling.""" + regex = r'^text and language cannot be empty$' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbMonolingualText(text='', language='sv') + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbMonolingualText(text='Test this!', language='') + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbMonolingualText(text=None, language='sv') + + +class TestWbGeoShapeNonDry(WbRepresentationTestCase): + + """Test Wikibase WbGeoShape data type (non-dry). + + These require non dry tests due to the page.exists() call. + """ + + def setUp(self): + """Setup tests.""" + self.commons = pywikibot.Site('commons') + self.page = Page(self.commons, 'Data:Lyngby Hovedgade.map') + super().setUp() + + def test_WbGeoShape_WbRepresentation_methods(self): + """Test inherited or extended methods from _WbRepresentation.""" + q = pywikibot.WbGeoShape(self.page) + self._test_hashable(q) + + def test_WbGeoShape_page(self): + """Test WbGeoShape page.""" + q = pywikibot.WbGeoShape(self.page) + q_val = 'Data:Lyngby Hovedgade.map' + self.assertEqual(q.toWikibase(), q_val) + + def test_WbGeoShape_page_and_site(self): + """Test WbGeoShape from page and site.""" + q = pywikibot.WbGeoShape(self.page, self.get_repo()) + q_val = 'Data:Lyngby Hovedgade.map' + self.assertEqual(q.toWikibase(), q_val) + + def test_WbGeoShape_equality(self): + """Test WbGeoShape equality.""" + q = pywikibot.WbGeoShape(self.page, self.get_repo()) + self.assertEqual(q, q) + + def test_WbGeoShape_fromWikibase(self): + """Test WbGeoShape.fromWikibase() instantiating.""" + repo = self.get_repo() + q = pywikibot.WbGeoShape.fromWikibase( + 'Data:Lyngby Hovedgade.map', repo) + self.assertEqual(q.toWikibase(), 'Data:Lyngby Hovedgade.map') + + def test_WbGeoShape_error_on_non_page(self): + """Test WbGeoShape error handling when given a non-page.""" + regex = r'^Page .+? must be a pywikibot\.Page object not a' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbGeoShape('A string', self.get_repo()) + + def test_WbGeoShape_error_on_non_exitant_page(self): + """Test WbGeoShape error handling of a non-existant page.""" + page = Page(self.commons, 'Non-existant page... really') + regex = r'^Page \[\[.+?\]\] must exist\.$' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbGeoShape(page, self.get_repo()) + + def test_WbGeoShape_error_on_wrong_site(self): + """Test WbGeoShape error handling of a page on non-filerepo site.""" + repo = self.get_repo() + page = Page(repo, 'Q123') + regex = r'^Page must be on the geo-shape repository site\.$' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbGeoShape(page, self.get_repo()) + + def test_WbGeoShape_error_on_wrong_page_type(self): + """Test WbGeoShape error handling of a non-map page.""" + non_data_page = Page(self.commons, 'File:Foo.jpg') + non_map_page = Page(self.commons, 'Data:TemplateData/TemplateData.tab') + regex = (r"^Page must be in 'Data:' namespace and end in '\.map' " + r'for geo-shape\.$') + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbGeoShape(non_data_page, self.get_repo()) + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbGeoShape(non_map_page, self.get_repo()) + + +class TestWbTabularDataNonDry(WbRepresentationTestCase): + + """Test Wikibase WbTabularData data type (non-dry). + + These require non dry tests due to the page.exists() call. + """ + + def setUp(self): + """Setup tests.""" + self.commons = pywikibot.Site('commons') + self.page = Page(self.commons, 'Data:Bea.gov/GDP by state.tab') + super().setUp() + + def test_WbTabularData_WbRepresentation_methods(self): + """Test inherited or extended methods from _WbRepresentation.""" + q = pywikibot.WbTabularData(self.page) + self._test_hashable(q) + + def test_WbTabularData_page(self): + """Test WbTabularData page.""" + q = pywikibot.WbTabularData(self.page) + q_val = 'Data:Bea.gov/GDP by state.tab' + self.assertEqual(q.toWikibase(), q_val) + + def test_WbTabularData_page_and_site(self): + """Test WbTabularData from page and site.""" + q = pywikibot.WbTabularData(self.page, self.get_repo()) + q_val = 'Data:Bea.gov/GDP by state.tab' + self.assertEqual(q.toWikibase(), q_val) + + def test_WbTabularData_equality(self): + """Test WbTabularData equality.""" + q = pywikibot.WbTabularData(self.page, self.get_repo()) + self.assertEqual(q, q) + + def test_WbTabularData_fromWikibase(self): + """Test WbTabularData.fromWikibase() instantiating.""" + repo = self.get_repo() + q = pywikibot.WbTabularData.fromWikibase( + 'Data:Bea.gov/GDP by state.tab', repo) + self.assertEqual(q.toWikibase(), 'Data:Bea.gov/GDP by state.tab') + + def test_WbTabularData_error_on_non_page(self): + """Test WbTabularData error handling when given a non-page.""" + regex = r'^Page .+? must be a pywikibot\.Page object not a' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbTabularData('A string', self.get_repo()) + + def test_WbTabularData_error_on_non_exitant_page(self): + """Test WbTabularData error handling of a non-existant page.""" + page = Page(self.commons, 'Non-existant page... really') + regex = r'^Page \[\[.+?\]\] must exist\.$' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbTabularData(page, self.get_repo()) + + def test_WbTabularData_error_on_wrong_site(self): + """Test WbTabularData error handling of a page on non-filerepo site.""" + repo = self.get_repo() + page = Page(repo, 'Q123') + regex = r'^Page must be on the tabular-data repository site\.$' + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbTabularData(page, self.get_repo()) + + def test_WbTabularData_error_on_wrong_page_type(self): + """Test WbTabularData error handling of a non-map page.""" + non_data_page = Page(self.commons, 'File:Foo.jpg') + non_map_page = Page(self.commons, 'Data:Lyngby Hovedgade.map') + regex = (r"^Page must be in 'Data:' namespace and end in '\.tab' " + r'for tabular-data\.$') + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbTabularData(non_data_page, self.get_repo()) + with self.assertRaisesRegex(ValueError, regex): + pywikibot.WbTabularData(non_map_page, self.get_repo()) + + +class TestWbUnknown(WbRepresentationTestCase): + + """Test Wikibase WbUnknown data type.""" + + dry = True + + def test_WbUnknown_WbRepresentation_methods(self): + """Test inherited or extended methods from _WbRepresentation.""" + q_dict = {'text': 'Test that basics work', 'language': 'en'} + q = pywikibot.WbUnknown(q_dict) + self._test_hashable(q) + + def test_WbUnknown_string(self): + """Test WbUnknown string.""" + q_dict = {'text': 'Test that basics work', 'language': 'en'} + q = pywikibot.WbUnknown(q_dict) + self.assertEqual(q.toWikibase(), q_dict) + + def test_WbUnknown_equality(self): + """Test WbUnknown equality.""" + q_dict = {'text': 'Thou shall test this!', 'language': 'unknown'} + q = pywikibot.WbUnknown(q_dict) + self.assertEqual(q, q) + + def test_WbUnknown_fromWikibase(self): + """Test WbUnknown.fromWikibase() instantiating.""" + q = pywikibot.WbUnknown.fromWikibase({'text': 'Test this!', + 'language': 'en'}) + self.assertEqual(q.toWikibase(), + {'text': 'Test this!', 'language': 'en'}) + + +if __name__ == '__main__': + with suppress(SystemExit): + unittest.main() diff --git a/tests/wikibase_edit_tests.py b/tests/wikibase_edit_tests.py index d9069d6b15..338db402d5 100755 --- a/tests/wikibase_edit_tests.py +++ b/tests/wikibase_edit_tests.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Tests for editing Wikibase items. +"""Tests for editing Wikibase items. Tests which should fail should instead be in the TestWikibaseSaveTest class in edit_failiure_tests.py @@ -242,8 +241,7 @@ class TestWikibaseMakeClaim(WikibaseTestCase): @staticmethod def _clean_item(repo, prop: str): - """ - Return an item without any existing claims of the given property. + """Return an item without any existing claims of the given property. :param repo: repository to fetch item from :type repo: pywikibot.site.DataSite @@ -617,8 +615,7 @@ class TestWikibaseAddClaimToExisting(WikibaseTestCase): @staticmethod def _clean_item_temp(repo, prop: str): - """ - Return an item without any existing claims of the given property. + """Return an item without any existing claims of the given property. :param repo: repository to fetch item from :type repo: pywikibot.site.DataSite diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py index 0a1f9c29b1..dfb45183e2 100755 --- a/tests/wikibase_tests.py +++ b/tests/wikibase_tests.py @@ -8,12 +8,9 @@ from __future__ import annotations import copy -import datetime import json -import operator import unittest from contextlib import suppress -from decimal import Decimal import pywikibot from pywikibot import pagegenerators @@ -25,9 +22,9 @@ UnknownExtensionError, WikiBaseError, ) -from pywikibot.page import ItemPage, Page, PropertyPage, WikibasePage +from pywikibot.page import ItemPage, PropertyPage, WikibasePage from pywikibot.site import Namespace, NamespacesDict -from pywikibot.tools import MediaWikiVersion, suppress_warnings +from pywikibot.tools import suppress_warnings from tests import WARN_SITE_CODE, join_pages_path from tests.aspects import TestCase, WikidataTestCase from tests.basepage import ( @@ -48,16 +45,6 @@ def _get_test_unconnected_page(site): return None # pragma: no cover -class WbRepresentationTestCase(WikidataTestCase): - - """Test methods inherited or extended from _WbRepresentation.""" - - def _test_hashable(self, representation): - """Test that the representation is hashable.""" - list_of_dupes = [representation, representation] - self.assertLength(set(list_of_dupes), 1) - - class TestLoadRevisionsCaching(BasePageLoadRevisionsCachingTestBase, WikidataTestCase): @@ -122,828 +109,8 @@ def test_cmp(self): ItemPage(self.get_repo(), 'q5296')) -class TestWikibaseCoordinate(WbRepresentationTestCase): - - """Test Wikibase Coordinate data type.""" - - dry = True - - def test_Coordinate_WbRepresentation_methods(self): - """Test inherited or extended methods from _WbRepresentation.""" - repo = self.get_repo() - coord = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0, - globe='moon') - self._test_hashable(coord) - - def test_Coordinate_dim(self): - """Test Coordinate dimension.""" - repo = self.get_repo() - x = pywikibot.Coordinate(site=repo, lat=12.0, lon=13.0, precision=5.0) - self.assertEqual(x.precisionToDim(), 544434) - self.assertIsInstance(x.precisionToDim(), int) - y = pywikibot.Coordinate(site=repo, lat=12.0, lon=13.0, dim=54444) - self.assertEqual(y.precision, 0.500005084017101) - self.assertIsInstance(y.precision, float) - z = pywikibot.Coordinate(site=repo, lat=12.0, lon=13.0) - regex = r'^No values set for dim or precision$' - with self.assertRaisesRegex(ValueError, regex): - z.precisionToDim() - - def test_Coordinate_plain_globe(self): - """Test setting Coordinate globe from a plain-text value.""" - repo = self.get_repo() - coord = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0, - globe='moon') - self.assertEqual(coord.toWikibase(), - {'latitude': 12.0, 'longitude': 13.0, - 'altitude': None, 'precision': 0, - 'globe': 'http://www.wikidata.org/entity/Q405'}) - - def test_Coordinate_entity_uri_globe(self): - """Test setting Coordinate globe from an entity uri.""" - repo = self.get_repo() - coord = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0, - globe_item='http://www.wikidata.org/entity/Q123') - self.assertEqual(coord.toWikibase(), - {'latitude': 12.0, 'longitude': 13.0, - 'altitude': None, 'precision': 0, - 'globe': 'http://www.wikidata.org/entity/Q123'}) - - -class TestWikibaseCoordinateNonDry(WbRepresentationTestCase): - - """ - Test Wikibase Coordinate data type (non-dry). - - These can be moved to TestWikibaseCoordinate once DrySite has been bumped - to the appropriate version. - """ - - def test_Coordinate_item_globe(self): - """Test setting Coordinate globe from an ItemPage.""" - repo = self.get_repo() - coord = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0, - globe_item=ItemPage(repo, 'Q123')) - self.assertEqual(coord.toWikibase(), - {'latitude': 12.0, 'longitude': 13.0, - 'altitude': None, 'precision': 0, - 'globe': 'http://www.wikidata.org/entity/Q123'}) - - def test_Coordinate_get_globe_item_from_uri(self): - """Test getting globe item from Coordinate with entity uri globe.""" - repo = self.get_repo() - q = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0, - globe_item='http://www.wikidata.org/entity/Q123') - self.assertEqual(q.get_globe_item(), ItemPage(repo, 'Q123')) - - def test_Coordinate_get_globe_item_from_itempage(self): - """Test getting globe item from Coordinate with ItemPage globe.""" - repo = self.get_repo() - globe = ItemPage(repo, 'Q123') - q = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0, globe_item=globe) - self.assertEqual(q.get_globe_item(), ItemPage(repo, 'Q123')) - - def test_Coordinate_get_globe_item_from_plain_globe(self): - """Test getting globe item from Coordinate with plain text globe.""" - repo = self.get_repo() - q = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0, globe='moon') - self.assertEqual(q.get_globe_item(), ItemPage(repo, 'Q405')) - - def test_Coordinate_get_globe_item_provide_repo(self): - """Test getting globe item from Coordinate, providing repo.""" - repo = self.get_repo() - q = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0, - globe_item='http://www.wikidata.org/entity/Q123') - self.assertEqual(q.get_globe_item(repo), ItemPage(repo, 'Q123')) - - def test_Coordinate_get_globe_item_different_repo(self): - """Test getting globe item in different repo from Coordinate.""" - repo = self.get_repo() - test_repo = pywikibot.Site('test', 'wikidata') - q = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0, - globe_item='http://test.wikidata.org/entity/Q123') - self.assertEqual(q.get_globe_item(test_repo), - ItemPage(test_repo, 'Q123')) - - def test_Coordinate_equality(self): - """Test Coordinate equality with different globe representations.""" - repo = self.get_repo() - a = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0.1, - globe='moon') - b = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0.1, - globe_item='http://www.wikidata.org/entity/Q405') - c = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0.1, - globe_item=ItemPage(repo, 'Q405')) - d = pywikibot.Coordinate( - site=repo, lat=12.0, lon=13.0, precision=0.1, - globe_item='http://test.wikidata.org/entity/Q405') - self.assertEqual(a, b) - self.assertEqual(b, c) - self.assertEqual(c, a) - self.assertNotEqual(a, d) - self.assertNotEqual(b, d) - self.assertNotEqual(c, d) - - -class TestWbTime(WbRepresentationTestCase): - - """Test Wikibase WbTime data type.""" - - dry = True - - def test_WbTime_WbRepresentation_methods(self): - """Test inherited or extended methods from _WbRepresentation.""" - repo = self.get_repo() - t = pywikibot.WbTime(site=repo, year=2010, month=0, day=0, hour=12, - minute=43) - self._test_hashable(t) - - def test_WbTime_timestr(self): - """Test timestr functions of WbTime.""" - repo = self.get_repo() - t = pywikibot.WbTime(site=repo, year=2010, month=0, day=0, hour=12, - minute=43) - self.assertEqual(t.toTimestr(), '+00000002010-00-00T12:43:00Z') - self.assertEqual(t.toTimestr(force_iso=True), '+2010-01-01T12:43:00Z') - - t = pywikibot.WbTime(site=repo, year=2010, hour=12, minute=43) - self.assertEqual(t.toTimestr(), '+00000002010-01-01T12:43:00Z') - self.assertEqual(t.toTimestr(force_iso=True), '+2010-01-01T12:43:00Z') - - t = pywikibot.WbTime(site=repo, year=-2010, hour=12, minute=43) - self.assertEqual(t.toTimestr(), '-00000002010-01-01T12:43:00Z') - self.assertEqual(t.toTimestr(force_iso=True), '-2010-01-01T12:43:00Z') - - def test_WbTime_fromTimestr(self): - """Test WbTime creation from UTC date/time string.""" - repo = self.get_repo() - t = pywikibot.WbTime.fromTimestr('+00000002010-01-01T12:43:00Z', - site=repo) - self.assertEqual(t, pywikibot.WbTime(site=repo, year=2010, hour=12, - minute=43, precision=14)) - - def test_WbTime_zero_month(self): - """Test WbTime creation from date/time string with zero month.""" - # ensures we support formats in T123888 / T107870 - repo = self.get_repo() - t = pywikibot.WbTime.fromTimestr('+00000002010-00-00T12:43:00Z', - site=repo) - self.assertEqual(t, pywikibot.WbTime(site=repo, year=2010, month=0, - day=0, hour=12, minute=43, - precision=14)) - - def test_WbTime_skip_params_precision(self): - """Test skipping units (such as day, month) when creating WbTimes.""" - repo = self.get_repo() - t = pywikibot.WbTime(year=2020, day=2, site=repo) - self.assertEqual(t, pywikibot.WbTime(year=2020, month=1, day=2, - site=repo)) - self.assertEqual(t.precision, pywikibot.WbTime.PRECISION['day']) - t2 = pywikibot.WbTime(year=2020, hour=5, site=repo) - self.assertEqual(t2, pywikibot.WbTime(year=2020, month=1, day=1, - hour=5, site=repo)) - self.assertEqual(t2.precision, pywikibot.WbTime.PRECISION['hour']) - t3 = pywikibot.WbTime(year=2020, minute=5, site=repo) - self.assertEqual(t3, pywikibot.WbTime(year=2020, month=1, day=1, - hour=0, minute=5, site=repo)) - self.assertEqual(t3.precision, pywikibot.WbTime.PRECISION['minute']) - t4 = pywikibot.WbTime(year=2020, second=5, site=repo) - self.assertEqual(t4, pywikibot.WbTime(year=2020, month=1, day=1, - hour=0, minute=0, second=5, - site=repo)) - self.assertEqual(t4.precision, pywikibot.WbTime.PRECISION['second']) - t5 = pywikibot.WbTime(year=2020, month=2, hour=5, site=repo) - self.assertEqual(t5, pywikibot.WbTime(year=2020, month=2, day=1, - hour=5, site=repo)) - self.assertEqual(t5.precision, pywikibot.WbTime.PRECISION['hour']) - t6 = pywikibot.WbTime(year=2020, month=2, minute=5, site=repo) - self.assertEqual(t6, pywikibot.WbTime(year=2020, month=2, day=1, - hour=0, minute=5, site=repo)) - self.assertEqual(t6.precision, pywikibot.WbTime.PRECISION['minute']) - t7 = pywikibot.WbTime(year=2020, month=2, second=5, site=repo) - self.assertEqual(t7, pywikibot.WbTime(year=2020, month=2, day=1, - hour=0, minute=0, second=5, - site=repo)) - self.assertEqual(t7.precision, pywikibot.WbTime.PRECISION['second']) - t8 = pywikibot.WbTime(year=2020, day=2, hour=5, site=repo) - self.assertEqual(t8, pywikibot.WbTime(year=2020, month=1, day=2, - hour=5, site=repo)) - self.assertEqual(t8.precision, pywikibot.WbTime.PRECISION['hour']) - t9 = pywikibot.WbTime(year=2020, month=3, day=2, minute=5, site=repo) - self.assertEqual(t9, pywikibot.WbTime(year=2020, month=3, day=2, - hour=0, minute=5, site=repo)) - self.assertEqual(t9.precision, pywikibot.WbTime.PRECISION['minute']) - - def test_WbTime_normalization(self): - """Test WbTime normalization.""" - repo = self.get_repo() - # flake8 is being annoying, so to reduce line length, I'll make - # some aliases here - decade = pywikibot.WbTime.PRECISION['decade'] - century = pywikibot.WbTime.PRECISION['century'] - millenia = pywikibot.WbTime.PRECISION['millenia'] - t = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12) - t2 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, - precision=pywikibot.WbTime.PRECISION['second']) - t3 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, - precision=pywikibot.WbTime.PRECISION['minute']) - t4 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, - precision=pywikibot.WbTime.PRECISION['hour']) - t5 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, - precision=pywikibot.WbTime.PRECISION['day']) - t6 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, - precision=pywikibot.WbTime.PRECISION['month']) - t7 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, - precision=pywikibot.WbTime.PRECISION['year']) - t8 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, - precision=decade) - t9 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, - precision=century) - t10 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, - precision=millenia) - t11 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, timezone=-300, - precision=pywikibot.WbTime.PRECISION['day']) - t12 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, timezone=300, - precision=pywikibot.WbTime.PRECISION['day']) - t13 = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=12, timezone=-300, - precision=pywikibot.WbTime.PRECISION['hour']) - self.assertEqual(t.normalize(), t) - self.assertEqual(t2.normalize(), t.normalize()) - self.assertEqual(t3.normalize(), - pywikibot.WbTime(site=repo, year=2010, month=1, - day=1, hour=12, minute=43)) - self.assertEqual(t4.normalize(), - pywikibot.WbTime(site=repo, year=2010, - month=1, day=1, hour=12)) - self.assertEqual(t5.normalize(), - pywikibot.WbTime(site=repo, year=2010, - month=1, day=1)) - self.assertEqual(t6.normalize(), - pywikibot.WbTime(site=repo, year=2010, - month=1)) - self.assertEqual( - t7.normalize(), pywikibot.WbTime(site=repo, year=2010)) - self.assertEqual(t8.normalize(), - pywikibot.WbTime(site=repo, year=2010, - precision=decade)) - self.assertEqual(t9.normalize(), - pywikibot.WbTime(site=repo, year=2100, - precision=century)) - self.assertEqual(t9.normalize(), - pywikibot.WbTime(site=repo, year=2010, - precision=century).normalize()) - self.assertEqual(t10.normalize(), - pywikibot.WbTime(site=repo, year=3000, - precision=millenia)) - self.assertEqual(t10.normalize(), - pywikibot.WbTime(site=repo, year=2010, - precision=millenia).normalize()) - t11_normalized = t11.normalize() - t12_normalized = t12.normalize() - self.assertEqual(t11_normalized.timezone, 0) - self.assertEqual(t12_normalized.timezone, 0) - self.assertNotEqual(t11, t12) - self.assertEqual(t11_normalized, t12_normalized) - self.assertEqual(t13.normalize().timezone, -300) - - def test_WbTime_normalization_very_low_precision(self): - """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'] - year_10000000 = pywikibot.WbTime.PRECISION['10000000'] - year_100000000 = pywikibot.WbTime.PRECISION['100000000'] - year_1000000000 = pywikibot.WbTime.PRECISION['1000000000'] - t = pywikibot.WbTime(site=repo, year=-3124684989, - precision=year_10000) - t2 = pywikibot.WbTime(site=repo, year=-3124684989, - precision=year_100000) - t3 = pywikibot.WbTime(site=repo, year=-3124684989, - precision=year_1000000) - t4 = pywikibot.WbTime(site=repo, year=-3124684989, - precision=year_10000000) - t5 = pywikibot.WbTime(site=repo, year=-3124684989, - precision=year_100000000) - t6 = pywikibot.WbTime(site=repo, year=-3124684989, - precision=year_1000000000) - self.assertEqual(t.normalize(), - pywikibot.WbTime(site=repo, year=-3124680000, - precision=year_10000)) - self.assertEqual(t2.normalize(), - pywikibot.WbTime(site=repo, year=-3124700000, - precision=year_100000)) - self.assertEqual(t3.normalize(), - pywikibot.WbTime(site=repo, year=-3125000000, - precision=year_1000000)) - self.assertEqual(t4.normalize(), - pywikibot.WbTime(site=repo, year=-3120000000, - precision=year_10000000)) - self.assertEqual(t5.normalize(), - pywikibot.WbTime(site=repo, year=-3100000000, - precision=year_100000000)) - self.assertEqual(t6.normalize(), - pywikibot.WbTime(site=repo, year=-3000000000, - precision=year_1000000000)) - - def test_WbTime_timestamp(self): - """Test timestamp functions of WbTime.""" - repo = self.get_repo() - timestamp = pywikibot.Timestamp.fromISOformat('2010-01-01T12:43:00Z') - t = pywikibot.WbTime(site=repo, year=2010, month=0, day=0, hour=12, - minute=43) - self.assertEqual(t.toTimestamp(), timestamp) - - # Roundtrip fails as Timestamp and WbTime interpret month=0 differently - self.assertNotEqual( - t, pywikibot.WbTime.fromTimestamp(timestamp, site=repo)) - - t = pywikibot.WbTime(site=repo, year=2010, hour=12, minute=43) - self.assertEqual(t.toTimestamp(), timestamp) - - t = pywikibot.WbTime(site=repo, year=-2010, hour=12, minute=43) - regex = r'^You cannot turn BC dates into a Timestamp$' - with self.assertRaisesRegex(ValueError, regex): - t.toTimestamp() - - t = pywikibot.WbTime(site=repo, year=2010, month=1, day=1, hour=12, - minute=43, second=0) - self.assertEqual(t.toTimestamp(), timestamp) - self.assertEqual( - t, pywikibot.WbTime.fromTimestamp(timestamp, site=repo)) - timezone = datetime.timezone(datetime.timedelta(hours=-5)) - ts = pywikibot.Timestamp(2020, 1, 1, 12, 43, 0, tzinfo=timezone) - t = pywikibot.WbTime.fromTimestamp(ts, site=repo, copy_timezone=True) - self.assertEqual(t.timezone, -5 * 60) - t = pywikibot.WbTime.fromTimestamp(ts, site=repo, copy_timezone=True, - timezone=60) - self.assertEqual(t.timezone, 60) - - ts1 = pywikibot.Timestamp( - year=2022, month=12, day=21, hour=13, - tzinfo=datetime.timezone(datetime.timedelta(hours=-5))) - t1 = pywikibot.WbTime.fromTimestamp(ts1, timezone=-300, site=repo) - self.assertIsNotNone(t1.toTimestamp(timezone_aware=True).tzinfo) - self.assertIsNone(t1.toTimestamp(timezone_aware=False).tzinfo) - self.assertEqual(t1.toTimestamp(timezone_aware=True), ts1) - self.assertNotEqual(t1.toTimestamp(timezone_aware=False), ts1) - - def test_WbTime_errors(self): - """Test WbTime precision errors.""" - repo = self.get_repo() - regex = r'^no year given$' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbTime(site=repo, precision=15) - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbTime(site=repo, precision='invalid_precision') - regex = r'^Invalid precision: "15"$' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbTime(site=repo, year=2020, precision=15) - regex = r'^Invalid precision: "invalid_precision"$' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbTime(site=repo, year=2020, - precision='invalid_precision') - - def test_comparison(self): - """Test WbTime comparison.""" - repo = self.get_repo() - t1 = pywikibot.WbTime(site=repo, year=2010, hour=12, minute=43) - t2 = pywikibot.WbTime(site=repo, year=-2005, hour=16, minute=45) - self.assertEqual(t1.precision, pywikibot.WbTime.PRECISION['minute']) - self.assertEqual(t1, t1) - self.assertGreaterEqual(t1, t1) - self.assertGreaterEqual(t1, t2) - self.assertGreater(t1, t2) - self.assertEqual(t1.year, 2010) - self.assertEqual(t2.year, -2005) - self.assertEqual(t1.month, 1) - self.assertEqual(t2.month, 1) - self.assertEqual(t1.day, 1) - self.assertEqual(t2.day, 1) - self.assertEqual(t1.hour, 12) - self.assertEqual(t2.hour, 16) - self.assertEqual(t1.minute, 43) - self.assertEqual(t2.minute, 45) - self.assertEqual(t1.second, 0) - 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') - self.assertIsInstance(t1.toTimestamp(), pywikibot.Timestamp) - self.assertRaises(ValueError, t2.toTimestamp) - - def test_comparison_types(self): - """Test WbTime comparison with different types.""" - repo = self.get_repo() - 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) - - def test_comparison_timezones(self): - """Test comparisons with timezones.""" - repo = self.get_repo() - ts1 = pywikibot.Timestamp( - year=2022, month=12, day=21, hour=13, - tzinfo=datetime.timezone(datetime.timedelta(hours=-5))) - ts2 = pywikibot.Timestamp( - year=2022, month=12, day=21, hour=17, - tzinfo=datetime.timezone.utc) - self.assertGreater(ts1.timestamp(), ts2.timestamp()) - - t1 = pywikibot.WbTime.fromTimestamp(ts1, timezone=-300, site=repo) - t2 = pywikibot.WbTime.fromTimestamp(ts2, timezone=0, site=repo) - self.assertGreater(t1, t2) - - def test_comparison_timezones_equal(self): - """Test when two WbTime's have equal instants but not the same tz.""" - repo = self.get_repo() - ts1 = pywikibot.Timestamp( - year=2023, month=12, day=21, hour=13, - tzinfo=datetime.timezone(datetime.timedelta(hours=-5))) - ts2 = pywikibot.Timestamp( - year=2023, month=12, day=21, hour=18, - tzinfo=datetime.timezone.utc) - self.assertEqual(ts1.timestamp(), ts2.timestamp()) - - t1 = pywikibot.WbTime.fromTimestamp(ts1, timezone=-300, site=repo) - t2 = pywikibot.WbTime.fromTimestamp(ts2, timezone=0, site=repo) - self.assertGreaterEqual(t1, t2) - self.assertGreaterEqual(t2, t1) - self.assertNotEqual(t1, t2) - self.assertNotEqual(t2, t1) - # Ignore H205: We specifically want to test the operator - self.assertFalse(t1 > t2) # noqa: H205 - self.assertFalse(t2 > t1) # noqa: H205 - self.assertFalse(t1 < t2) # noqa: H205 - self.assertFalse(t2 < t1) # noqa: H205 - - def test_comparison_equal_instant(self): - """Test the equal_instant method.""" - repo = self.get_repo() - - ts1 = pywikibot.Timestamp( - year=2023, month=12, day=21, hour=13, - tzinfo=datetime.timezone(datetime.timedelta(hours=-5))) - ts2 = pywikibot.Timestamp( - year=2023, month=12, day=21, hour=18, - tzinfo=datetime.timezone.utc) - ts3 = pywikibot.Timestamp( - year=2023, month=12, day=21, hour=19, - tzinfo=datetime.timezone(datetime.timedelta(hours=1))) - ts4 = pywikibot.Timestamp( - year=2023, month=12, day=21, hour=13, - tzinfo=datetime.timezone(datetime.timedelta(hours=-6))) - - self.assertEqual(ts1.timestamp(), ts2.timestamp()) - self.assertEqual(ts1.timestamp(), ts3.timestamp()) - self.assertEqual(ts2.timestamp(), ts3.timestamp()) - self.assertNotEqual(ts1.timestamp(), ts4.timestamp()) - self.assertNotEqual(ts2.timestamp(), ts4.timestamp()) - self.assertNotEqual(ts3.timestamp(), ts4.timestamp()) - - t1 = pywikibot.WbTime.fromTimestamp(ts1, timezone=-300, site=repo) - t2 = pywikibot.WbTime.fromTimestamp(ts2, timezone=0, site=repo) - t3 = pywikibot.WbTime.fromTimestamp(ts3, timezone=60, site=repo) - t4 = pywikibot.WbTime.fromTimestamp(ts4, timezone=-360, site=repo) - - self.assertTrue(t1.equal_instant(t2)) - self.assertTrue(t1.equal_instant(t3)) - self.assertTrue(t2.equal_instant(t3)) - self.assertFalse(t1.equal_instant(t4)) - self.assertFalse(t2.equal_instant(t4)) - self.assertFalse(t3.equal_instant(t4)) - - -class TestWbQuantity(WbRepresentationTestCase): - - """Test Wikibase WbQuantity data type.""" - - dry = True - - def test_WbQuantity_WbRepresentation_methods(self): - """Test inherited or extended methods from _WbRepresentation.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount=1234, error=1, site=repo) - self._test_hashable(q) - - def test_WbQuantity_integer(self): - """Test WbQuantity for integer value.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount=1234, error=1, site=repo) - self.assertEqual(q.toWikibase(), - {'amount': '+1234', 'lowerBound': '+1233', - 'upperBound': '+1235', 'unit': '1'}) - q = pywikibot.WbQuantity(amount=5, error=(2, 3), site=repo) - self.assertEqual(q.toWikibase(), - {'amount': '+5', 'lowerBound': '+2', - 'upperBound': '+7', 'unit': '1'}) - q = pywikibot.WbQuantity(amount=0, error=(0, 0), site=repo) - self.assertEqual(q.toWikibase(), - {'amount': '+0', 'lowerBound': '+0', - 'upperBound': '+0', 'unit': '1'}) - q = pywikibot.WbQuantity(amount=-5, error=(2, 3), site=repo) - self.assertEqual(q.toWikibase(), - {'amount': '-5', 'lowerBound': '-8', - 'upperBound': '-3', 'unit': '1'}) - - def test_WbQuantity_float_27(self): - """Test WbQuantity for float value.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount=0.044405586, error=0.0, site=repo) - q_dict = {'amount': '+0.044405586', 'lowerBound': '+0.044405586', - 'upperBound': '+0.044405586', 'unit': '1'} - self.assertEqual(q.toWikibase(), q_dict) - - def test_WbQuantity_scientific(self): - """Test WbQuantity for scientific notation.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount='1.3e-13', error='1e-14', site=repo) - q_dict = {'amount': '+1.3e-13', 'lowerBound': '+1.2e-13', - 'upperBound': '+1.4e-13', 'unit': '1'} - self.assertEqual(q.toWikibase(), q_dict) - - def test_WbQuantity_decimal(self): - """Test WbQuantity for decimal value.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount=Decimal('0.044405586'), - error=Decimal('0.0'), site=repo) - q_dict = {'amount': '+0.044405586', 'lowerBound': '+0.044405586', - 'upperBound': '+0.044405586', 'unit': '1'} - self.assertEqual(q.toWikibase(), q_dict) - - def test_WbQuantity_string(self): - """Test WbQuantity for decimal notation.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount='0.044405586', error='0', site=repo) - q_dict = {'amount': '+0.044405586', 'lowerBound': '+0.044405586', - 'upperBound': '+0.044405586', 'unit': '1'} - self.assertEqual(q.toWikibase(), q_dict) - - def test_WbQuantity_formatting_bound(self): - """Test WbQuantity formatting with bounds.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount='0.044405586', 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')) - self.assertEqual(repr(q), - 'WbQuantity(amount={val}, ' - 'upperBound={val}, lowerBound={val}, ' - 'unit=1)'.format(val='0.044405586')) - - def test_WbQuantity_self_equality(self): - """Test WbQuantity equality.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount='0.044405586', error='0', site=repo) - self.assertEqual(q, q) - - def test_WbQuantity_fromWikibase(self): - """Test WbQuantity.fromWikibase() instantiating.""" - repo = self.get_repo() - q = pywikibot.WbQuantity.fromWikibase({'amount': '+0.0229', - 'lowerBound': '0', - 'upperBound': '1', - 'unit': '1'}, - site=repo) - # note that the bounds are inputted as INT but are returned as FLOAT - self.assertEqual(q.toWikibase(), - {'amount': '+0.0229', 'lowerBound': '+0.0000', - 'upperBound': '+1.0000', 'unit': '1'}) - - def test_WbQuantity_errors(self): - """Test WbQuantity error handling.""" - regex = r'^no amount given$' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbQuantity(amount=None, error=1) - - def test_WbQuantity_entity_unit(self): - """Test WbQuantity with entity uri unit.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount=1234, error=1, site=repo, - unit='http://www.wikidata.org/entity/Q712226') - self.assertEqual(q.toWikibase(), - {'amount': '+1234', 'lowerBound': '+1233', - 'upperBound': '+1235', - 'unit': 'http://www.wikidata.org/entity/Q712226'}) - - def test_WbQuantity_unit_fromWikibase(self): - """Test WbQuantity recognising unit from Wikibase output.""" - repo = self.get_repo() - q = pywikibot.WbQuantity.fromWikibase({ - 'amount': '+1234', 'lowerBound': '+1233', 'upperBound': '+1235', - 'unit': 'http://www.wikidata.org/entity/Q712226'}, - site=repo) - self.assertEqual(q.toWikibase(), - {'amount': '+1234', 'lowerBound': '+1233', - 'upperBound': '+1235', - 'unit': 'http://www.wikidata.org/entity/Q712226'}) - - -class TestWbQuantityNonDry(WbRepresentationTestCase): - - """ - Test Wikibase WbQuantity data type (non-dry). - - These can be moved to TestWbQuantity once DrySite has been bumped to - the appropriate version. - """ - - def setUp(self): - """Override setup to store repo and it's version.""" - super().setUp() - self.repo = self.get_repo() - self.version = self.repo.mw_version - - def test_WbQuantity_unbound(self): - """Test WbQuantity for value without bounds.""" - if self.version < MediaWikiVersion('1.29.0-wmf.2'): - self.skipTest('Wiki version must be 1.29.0-wmf.2 or newer to ' - 'support unbound uncertainties.') - q = pywikibot.WbQuantity(amount=1234.5, site=self.repo) - self.assertEqual(q.toWikibase(), - {'amount': '+1234.5', 'unit': '1', - 'upperBound': None, 'lowerBound': None}) - - def test_WbQuantity_formatting_unbound(self): - """Test WbQuantity formatting without bounds.""" - if self.version < MediaWikiVersion('1.29.0-wmf.2'): - self.skipTest('Wiki version must be 1.29.0-wmf.2 or newer to ' - 'support unbound uncertainties.') - q = pywikibot.WbQuantity(amount='0.044405586', site=self.repo) - self.assertEqual(str(q), - '{{\n' - ' "amount": "+{val}",\n' - ' "lowerBound": null,\n' - ' "unit": "1",\n' - ' "upperBound": null\n' - '}}'.format(val='0.044405586')) - self.assertEqual(repr(q), - 'WbQuantity(amount={val}, ' - 'upperBound=None, lowerBound=None, ' - 'unit=1)'.format(val='0.044405586')) - - def test_WbQuantity_fromWikibase_unbound(self): - """Test WbQuantity.fromWikibase() instantiating without bounds.""" - if self.version < MediaWikiVersion('1.29.0-wmf.2'): - self.skipTest('Wiki version must be 1.29.0-wmf.2 or newer to ' - 'support unbound uncertainties.') - q = pywikibot.WbQuantity.fromWikibase({'amount': '+0.0229', - 'unit': '1'}, - site=self.repo) - self.assertEqual(q.toWikibase(), - {'amount': '+0.0229', 'lowerBound': None, - 'upperBound': None, 'unit': '1'}) - - def test_WbQuantity_ItemPage_unit(self): - """Test WbQuantity with ItemPage unit.""" - if self.version < MediaWikiVersion('1.28-wmf.23'): - self.skipTest('Wiki version must be 1.28-wmf.23 or newer to ' - 'expose wikibase-conceptbaseuri.') - - q = pywikibot.WbQuantity(amount=1234, error=1, - unit=pywikibot.ItemPage(self.repo, 'Q712226')) - self.assertEqual(q.toWikibase(), - {'amount': '+1234', 'lowerBound': '+1233', - 'upperBound': '+1235', - 'unit': 'http://www.wikidata.org/entity/Q712226'}) - - def test_WbQuantity_equality(self): - """Test WbQuantity equality with different unit representations.""" - if self.version < MediaWikiVersion('1.28-wmf.23'): - self.skipTest('Wiki version must be 1.28-wmf.23 or newer to ' - 'expose wikibase-conceptbaseuri.') - - a = pywikibot.WbQuantity( - amount=1234, error=1, - unit=pywikibot.ItemPage(self.repo, 'Q712226')) - b = pywikibot.WbQuantity( - amount=1234, error=1, - unit='http://www.wikidata.org/entity/Q712226') - c = pywikibot.WbQuantity( - amount=1234, error=1, - unit='http://test.wikidata.org/entity/Q712226') - d = pywikibot.WbQuantity( - amount=1234, error=2, - unit='http://www.wikidata.org/entity/Q712226') - self.assertEqual(a, b) - self.assertNotEqual(a, c) - self.assertNotEqual(b, c) - self.assertNotEqual(b, d) - - def test_WbQuantity_get_unit_item(self): - """Test getting unit item from WbQuantity.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount=1234, error=1, site=repo, - unit='http://www.wikidata.org/entity/Q123') - self.assertEqual(q.get_unit_item(), - ItemPage(repo, 'Q123')) - - def test_WbQuantity_get_unit_item_provide_repo(self): - """Test getting unit item from WbQuantity, providing repo.""" - repo = self.get_repo() - q = pywikibot.WbQuantity(amount=1234, error=1, - unit='http://www.wikidata.org/entity/Q123') - self.assertEqual(q.get_unit_item(repo), - ItemPage(repo, 'Q123')) - - def test_WbQuantity_get_unit_item_different_repo(self): - """Test getting unit item in different repo from WbQuantity.""" - repo = self.get_repo() - test_repo = pywikibot.Site('test', 'wikidata') - q = pywikibot.WbQuantity(amount=1234, error=1, site=repo, - unit='http://test.wikidata.org/entity/Q123') - self.assertEqual(q.get_unit_item(test_repo), - ItemPage(test_repo, 'Q123')) - - -class TestWbMonolingualText(WbRepresentationTestCase): - - """Test Wikibase WbMonolingualText data type.""" - - dry = True - - def test_WbMonolingualText_WbRepresentation_methods(self): - """Test inherited or extended methods from _WbRepresentation.""" - q = pywikibot.WbMonolingualText( - text='Test that basics work', language='en') - self._test_hashable(q) - - def test_WbMonolingualText_string(self): - """Test WbMonolingualText string.""" - q = pywikibot.WbMonolingualText(text='Test that basics work', - language='en') - q_dict = {'text': 'Test that basics work', 'language': 'en'} - self.assertEqual(q.toWikibase(), q_dict) - - def test_WbMonolingualText_unicode(self): - """Test WbMonolingualText unicode.""" - q = pywikibot.WbMonolingualText(text='Testa det här', language='sv') - q_dict = {'text': 'Testa det här', 'language': 'sv'} - self.assertEqual(q.toWikibase(), q_dict) - - def test_WbMonolingualText_equality(self): - """Test WbMonolingualText equality.""" - q = pywikibot.WbMonolingualText(text='Thou shall test this!', - language='en-gb') - self.assertEqual(q, q) - - def test_WbMonolingualText_fromWikibase(self): - """Test WbMonolingualText.fromWikibase() instantiating.""" - q = pywikibot.WbMonolingualText.fromWikibase({'text': 'Test this!', - 'language': 'en'}) - self.assertEqual(q.toWikibase(), - {'text': 'Test this!', 'language': 'en'}) - - def test_WbMonolingualText_errors(self): - """Test WbMonolingualText error handling.""" - regex = r'^text and language cannot be empty$' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbMonolingualText(text='', language='sv') - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbMonolingualText(text='Test this!', language='') - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbMonolingualText(text=None, language='sv') - - class TestWikibaseParser(WikidataTestCase): + """Test passing various datatypes to wikibase parser.""" def test_wbparse_strings(self): @@ -990,190 +157,6 @@ def test_wbparse_raises_valueerror(self): self.site.parsevalue('quantity', ['Not a quantity']) -class TestWbGeoShapeNonDry(WbRepresentationTestCase): - - """ - Test Wikibase WbGeoShape data type (non-dry). - - These require non dry tests due to the page.exists() call. - """ - - def setUp(self): - """Setup tests.""" - self.commons = pywikibot.Site('commons') - self.page = Page(self.commons, 'Data:Lyngby Hovedgade.map') - super().setUp() - - def test_WbGeoShape_WbRepresentation_methods(self): - """Test inherited or extended methods from _WbRepresentation.""" - q = pywikibot.WbGeoShape(self.page) - self._test_hashable(q) - - def test_WbGeoShape_page(self): - """Test WbGeoShape page.""" - q = pywikibot.WbGeoShape(self.page) - q_val = 'Data:Lyngby Hovedgade.map' - self.assertEqual(q.toWikibase(), q_val) - - def test_WbGeoShape_page_and_site(self): - """Test WbGeoShape from page and site.""" - q = pywikibot.WbGeoShape(self.page, self.get_repo()) - q_val = 'Data:Lyngby Hovedgade.map' - self.assertEqual(q.toWikibase(), q_val) - - def test_WbGeoShape_equality(self): - """Test WbGeoShape equality.""" - q = pywikibot.WbGeoShape(self.page, self.get_repo()) - self.assertEqual(q, q) - - def test_WbGeoShape_fromWikibase(self): - """Test WbGeoShape.fromWikibase() instantiating.""" - repo = self.get_repo() - q = pywikibot.WbGeoShape.fromWikibase( - 'Data:Lyngby Hovedgade.map', repo) - self.assertEqual(q.toWikibase(), 'Data:Lyngby Hovedgade.map') - - def test_WbGeoShape_error_on_non_page(self): - """Test WbGeoShape error handling when given a non-page.""" - regex = r'^Page .+? must be a pywikibot\.Page object not a' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbGeoShape('A string', self.get_repo()) - - def test_WbGeoShape_error_on_non_exitant_page(self): - """Test WbGeoShape error handling of a non-existant page.""" - page = Page(self.commons, 'Non-existant page... really') - regex = r'^Page \[\[.+?\]\] must exist\.$' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbGeoShape(page, self.get_repo()) - - def test_WbGeoShape_error_on_wrong_site(self): - """Test WbGeoShape error handling of a page on non-filerepo site.""" - repo = self.get_repo() - page = Page(repo, 'Q123') - regex = r'^Page must be on the geo-shape repository site\.$' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbGeoShape(page, self.get_repo()) - - def test_WbGeoShape_error_on_wrong_page_type(self): - """Test WbGeoShape error handling of a non-map page.""" - non_data_page = Page(self.commons, 'File:Foo.jpg') - non_map_page = Page(self.commons, 'Data:TemplateData/TemplateData.tab') - regex = (r"^Page must be in 'Data:' namespace and end in '\.map' " - r'for geo-shape\.$') - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbGeoShape(non_data_page, self.get_repo()) - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbGeoShape(non_map_page, self.get_repo()) - - -class TestWbTabularDataNonDry(WbRepresentationTestCase): - - """ - Test Wikibase WbTabularData data type (non-dry). - - These require non dry tests due to the page.exists() call. - """ - - def setUp(self): - """Setup tests.""" - self.commons = pywikibot.Site('commons') - self.page = Page(self.commons, 'Data:Bea.gov/GDP by state.tab') - super().setUp() - - def test_WbTabularData_WbRepresentation_methods(self): - """Test inherited or extended methods from _WbRepresentation.""" - q = pywikibot.WbTabularData(self.page) - self._test_hashable(q) - - def test_WbTabularData_page(self): - """Test WbTabularData page.""" - q = pywikibot.WbTabularData(self.page) - q_val = 'Data:Bea.gov/GDP by state.tab' - self.assertEqual(q.toWikibase(), q_val) - - def test_WbTabularData_page_and_site(self): - """Test WbTabularData from page and site.""" - q = pywikibot.WbTabularData(self.page, self.get_repo()) - q_val = 'Data:Bea.gov/GDP by state.tab' - self.assertEqual(q.toWikibase(), q_val) - - def test_WbTabularData_equality(self): - """Test WbTabularData equality.""" - q = pywikibot.WbTabularData(self.page, self.get_repo()) - self.assertEqual(q, q) - - def test_WbTabularData_fromWikibase(self): - """Test WbTabularData.fromWikibase() instantiating.""" - repo = self.get_repo() - q = pywikibot.WbTabularData.fromWikibase( - 'Data:Bea.gov/GDP by state.tab', repo) - self.assertEqual(q.toWikibase(), 'Data:Bea.gov/GDP by state.tab') - - def test_WbTabularData_error_on_non_page(self): - """Test WbTabularData error handling when given a non-page.""" - regex = r'^Page .+? must be a pywikibot\.Page object not a' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbTabularData('A string', self.get_repo()) - - def test_WbTabularData_error_on_non_exitant_page(self): - """Test WbTabularData error handling of a non-existant page.""" - page = Page(self.commons, 'Non-existant page... really') - regex = r'^Page \[\[.+?\]\] must exist\.$' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbTabularData(page, self.get_repo()) - - def test_WbTabularData_error_on_wrong_site(self): - """Test WbTabularData error handling of a page on non-filerepo site.""" - repo = self.get_repo() - page = Page(repo, 'Q123') - regex = r'^Page must be on the tabular-data repository site\.$' - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbTabularData(page, self.get_repo()) - - def test_WbTabularData_error_on_wrong_page_type(self): - """Test WbTabularData error handling of a non-map page.""" - non_data_page = Page(self.commons, 'File:Foo.jpg') - non_map_page = Page(self.commons, 'Data:Lyngby Hovedgade.map') - regex = (r"^Page must be in 'Data:' namespace and end in '\.tab' " - r'for tabular-data\.$') - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbTabularData(non_data_page, self.get_repo()) - with self.assertRaisesRegex(ValueError, regex): - pywikibot.WbTabularData(non_map_page, self.get_repo()) - - -class TestWbUnknown(WbRepresentationTestCase): - - """Test Wikibase WbUnknown data type.""" - - dry = True - - def test_WbUnknown_WbRepresentation_methods(self): - """Test inherited or extended methods from _WbRepresentation.""" - q_dict = {'text': 'Test that basics work', 'language': 'en'} - q = pywikibot.WbUnknown(q_dict) - self._test_hashable(q) - - def test_WbUnknown_string(self): - """Test WbUnknown string.""" - q_dict = {'text': 'Test that basics work', 'language': 'en'} - q = pywikibot.WbUnknown(q_dict) - self.assertEqual(q.toWikibase(), q_dict) - - def test_WbUnknown_equality(self): - """Test WbUnknown equality.""" - q_dict = {'text': 'Thou shall test this!', 'language': 'unknown'} - q = pywikibot.WbUnknown(q_dict) - self.assertEqual(q, q) - - def test_WbUnknown_fromWikibase(self): - """Test WbUnknown.fromWikibase() instantiating.""" - q = pywikibot.WbUnknown.fromWikibase({'text': 'Test this!', - 'language': 'en'}) - self.assertEqual(q.toWikibase(), - {'text': 'Test this!', 'language': 'en'}) - - class TestLoadUnknownType(WikidataTestCase): """Test unknown datatypes being loaded as WbUnknown.""" @@ -1220,8 +203,7 @@ class MyItemPage(ItemPage): class TestItemLoad(WikidataTestCase): - """ - Test item creation. + """Test item creation. Tests for item creation include: 1. by Q id @@ -1303,21 +285,23 @@ def test_load_item_set_id(self): item.get() self.assertTrue(hasattr(item, '_content')) self.assertIn('en', item.labels) - self.assertEqual(item.labels['en'], 'New York City') + # label could change + self.assertIn(item.labels['en'], ['New York', 'New York City']) self.assertEqual(item.title(), 'Q60') def test_reuse_item_set_id(self): - """ - Test modifying item.id attribute. + """Test modifying item.id attribute. Some scripts are using item.id = 'Q60' semantics, which does work but modifying item.id does not currently work, and this test highlights that it breaks silently. """ + # label could change + label = ['New York', 'New York City'] wikidata = self.get_repo() item = ItemPage(wikidata, 'Q60') item.get() - self.assertEqual(item.labels['en'], 'New York City') + self.assertIn(item.labels['en'], label) # When the id attribute is modified, the ItemPage goes into # an inconsistent state. @@ -1329,13 +313,10 @@ def test_reuse_item_set_id(self): # it doesn't help to clear this piece of saved state. del item._content # The labels are not updated; assertion showing undesirable behaviour: - self.assertEqual(item.labels['en'], 'New York City') - # TODO: This is the assertion that this test should be using: - # self.assertTrue(item.labels['en'].lower().endswith('main page')) + self.assertIn(item.labels['en'], label) def test_empty_item(self): - """ - Test empty wikibase item. + """Test empty wikibase item. should not raise an error as the constructor only requires the site parameter, with the title parameter defaulted to None. @@ -1365,8 +346,7 @@ def test_item_invalid_titles(self): ItemPage(wikidata, '') def test_item_untrimmed_title(self): - """ - Test intrimmed titles of wikibase items. + """Test intrimmed titles of wikibase items. Spaces in the title should not cause an error. """ @@ -1540,8 +520,7 @@ def _test_fromPage_noitem(self, link): ItemPage.fromPage(page) def test_fromPage_redirect(self): - """ - Test item from redirect page. + """Test item from redirect page. A redirect should not have a wikidata item. """ @@ -1549,8 +528,7 @@ def test_fromPage_redirect(self): self._test_fromPage_noitem(link) def test_fromPage_missing(self): - """ - Test item from deleted page. + """Test item from deleted page. A deleted page should not have a wikidata item. """ @@ -1558,8 +536,7 @@ def test_fromPage_missing(self): self._test_fromPage_noitem(link) def test_fromPage_noitem(self): - """ - Test item from new page. + """Test item from new page. A new created page should not have a wikidata item yet. """ @@ -1754,8 +731,7 @@ class TestClaim(WikidataTestCase): """Test Claim object functionality.""" def test_claim_eq_simple(self): - """ - Test comparing two claims. + """Test comparing two claims. If they have the same property and value, they are equal. """ @@ -1768,8 +744,7 @@ def test_claim_eq_simple(self): self.assertEqual(claim2, claim1) def test_claim_eq_simple_different_value(self): - """ - Test comparing two claims. + """Test comparing two claims. If they have the same property and different values, they are not equal. @@ -1783,8 +758,7 @@ def test_claim_eq_simple_different_value(self): self.assertNotEqual(claim2, claim1) def test_claim_eq_simple_different_rank(self): - """ - Test comparing two claims. + """Test comparing two claims. If they have the same property and value and different ranks, they are equal. @@ -1799,8 +773,7 @@ def test_claim_eq_simple_different_rank(self): self.assertEqual(claim2, claim1) def test_claim_eq_simple_different_snaktype(self): - """ - Test comparing two claims. + """Test comparing two claims. If they have the same property and different snaktypes, they are not equal. @@ -1814,8 +787,7 @@ def test_claim_eq_simple_different_snaktype(self): self.assertNotEqual(claim2, claim1) def test_claim_eq_simple_different_property(self): - """ - Test comparing two claims. + """Test comparing two claims. If they have the same value and different properties, they are not equal. @@ -1829,8 +801,7 @@ def test_claim_eq_simple_different_property(self): self.assertNotEqual(claim2, claim1) def test_claim_eq_with_qualifiers(self): - """ - Test comparing two claims. + """Test comparing two claims. If they have the same property, value and qualifiers, they are equal. """ @@ -1849,8 +820,7 @@ def test_claim_eq_with_qualifiers(self): self.assertEqual(claim2, claim1) def test_claim_eq_with_different_qualifiers(self): - """ - Test comparing two claims. + """Test comparing two claims. If they have the same property and value and different qualifiers, they are not equal. @@ -1870,8 +840,7 @@ def test_claim_eq_with_different_qualifiers(self): self.assertNotEqual(claim2, claim1) def test_claim_eq_one_without_qualifiers(self): - """ - Test comparing two claims. + """Test comparing two claims. If they have the same property and value and one of them has no qualifiers while the other one does, they are not equal. @@ -1888,8 +857,7 @@ def test_claim_eq_one_without_qualifiers(self): self.assertNotEqual(claim2, claim1) def test_claim_eq_with_different_sources(self): - """ - Test comparing two claims. + """Test comparing two claims. If they have the same property and value and different sources, they are equal. @@ -1909,8 +877,7 @@ def test_claim_eq_with_different_sources(self): self.assertEqual(claim2, claim1) def test_claim_copy_is_equal(self): - """ - Test making a copy of a claim. + """Test making a copy of a claim. The copy of a claim should be always equal to the claim. """ @@ -1927,8 +894,7 @@ def test_claim_copy_is_equal(self): self.assertEqual(claim, copy) def test_claim_copy_is_equal_qualifier(self): - """ - Test making a copy of a claim. + """Test making a copy of a claim. The copy of a qualifier should be always equal to the qualifier. """ @@ -1941,8 +907,7 @@ def test_claim_copy_is_equal_qualifier(self): self.assertTrue(copy.isQualifier) def test_claim_copy_is_equal_source(self): - """ - Test making a copy of a claim. + """Test making a copy of a claim. The copy of a source should be always equal to the source. """ @@ -2204,8 +1169,7 @@ class TestNamespaces(WikidataTestCase): """Test cases to test namespaces of Wikibase entities.""" def test_empty_wikibase_page(self): - """ - Test empty wikibase page. + """Test empty wikibase page. As a base class it should be able to instantiate it with minimal arguments diff --git a/tox.ini b/tox.ini index 8f69dff930..4c83cfae36 100644 --- a/tox.ini +++ b/tox.ini @@ -118,8 +118,6 @@ deps = [flake8] # The following are intentionally ignored, possibly pending consensus -# D105: Missing docstring in magic method -# D211: No blank lines allowed before class docstring # E704: multiple statements on one line (def) # FI1: __future__ import "x" missing # H101: TODO format @@ -132,15 +130,7 @@ deps = # R100: raise in except handler without from # W503: line break before binary operator; against current PEP 8 recommendation -# Errors occurred after upgrade to pydocstyle 2.0.0 (T164142) -# D401: First line should be in imperative mood; try rephrasing -# D412: No blank lines allowed between a section header and its content -# D413: Missing blank line after last section - -# DARXXX: Darglint docstring issues to be solved -# DAR000: T368849 - -ignore = B007,D105,D211,D401,D413,D412,DAR000,DAR003,DAR101,DAR102,DAR201,DAR202,DAR301,DAR401,DAR402,DAR501,E704,H101,H231,H232,H233,H234,H235,H236,H237,H238,H301,H306,H404,H405,H903,R100,W503 +ignore = B007,E704,H101,H231,H232,H233,H234,H235,H236,H237,H238,H301,H306,H404,H405,H903,R100,W503 enable-extensions = H203,H204,H205,N818 count = True @@ -166,7 +156,6 @@ per-file-ignores = pywikibot/date.py: N802, N803, N806, N816 pywikibot/editor.py: N803, N806 pywikibot/exceptions.py: H501 - pywikibot/families/*: D102 pywikibot/family.py: N802, N803, N806, N815 pywikibot/fixes.py: E241 pywikibot/interwiki_graph.py: N802, N803, N806 @@ -191,7 +180,7 @@ per-file-ignores = scripts/clean_sandbox.py: N816 scripts/commonscat.py: N802, N806, N816 scripts/cosmetic_changes.py: N816 - scripts/dataextend.py: C901, D101, D102, E501 + scripts/dataextend.py: C901, E501 scripts/harvest_template.py: N802, N816 scripts/interwiki.py: N802, N803, N806, N816 scripts/imagetransfer.py: N803, N806, N816 @@ -227,7 +216,8 @@ per-file-ignores = tests/tools_formatter_tests.py: N802 tests/tools_tests.py: N802 tests/ui_options_tests.py: N802 - tests/ui_tests.py: D102, D103, N802 + tests/ui_tests.py: N802 + tests/wbtypes_tests.py: N802 tests/wikibase_edit_tests.py: N802 tests/wikibase_tests.py: N802 tests/xmlreader_tests.py: N802 @@ -242,8 +232,6 @@ classmethod-decorators = classmethod,classproperty [pycodestyle] exclude = .tox,.git,./*.egg,build,./scripts/i18n/* -# see explanations above -ignore = D105,D211 [pytest] minversion = 7.0.1