"""Objects representing API interface to MediaWiki site extenstions."""
#
# (C) Pywikibot team, 2008-2024
#
# Distributed under the terms of the MIT license.
#
from __future__ import annotations
from typing import Any
import pywikibot
from pywikibot.data import api
from pywikibot.echo import Notification
from pywikibot.exceptions import (
APIError,
Error,
InconsistentTitleError,
NoPageError,
SiteDefinitionError,
)
from pywikibot.site._decorators import need_extension, need_right
from pywikibot.tools import deprecated, merge_unique_dicts
[docs]
class EchoMixin:
"""APISite mixin for Echo extension."""
[docs]
@need_extension('Echo')
def notifications(self, **kwargs):
"""Yield Notification objects from the Echo extension.
:keyword Optional[str] format: If specified, notifications will
be returned formatted this way. Its value is either ``model``,
``special`` or ``None``. Default is ``special``.
.. seealso:: :api:`Notifications` for other keywords.
"""
params = {
'action': 'query',
'meta': 'notifications',
'notformat': 'special',
}
for key, value in kwargs.items():
params['not' + key] = value
data = self.simple_request(**params).submit()
notifications = data['query']['notifications']['list']
return (Notification.fromJSON(self, notification)
for notification in notifications)
[docs]
@need_extension('Echo')
def notifications_mark_read(self, **kwargs) -> bool:
"""Mark selected notifications as read.
.. seealso:: :api:`echomarkread`
:return: whether the action was successful
"""
# TODO: ensure that the 'echomarkread' action
# is supported by the site
kwargs = merge_unique_dicts(kwargs, action='echomarkread',
token=self.tokens['csrf'])
req = self.simple_request(**kwargs)
data = req.submit()
try:
return data['query']['echomarkread']['result'] == 'success'
except KeyError:
return False
[docs]
class ProofreadPageMixin:
"""APISite mixin for ProofreadPage extension."""
@need_extension('ProofreadPage')
def _cache_proofreadinfo(self, expiry=False) -> None:
"""Retrieve proofreadinfo from site and cache response.
Applicable only to sites with ProofreadPage extension installed.
The following info is returned by the query and cached:
- self._proofread_index_ns: Index Namespace
- self._proofread_page_ns: Page Namespace
- self._proofread_levels: a dictionary with::
keys: int in the range [0, 1, ..., 4]
values: category name corresponding to the 'key' quality level
e.g. on en.wikisource:
.. code-block:: python
{0: 'Without text', 1: 'Not proofread', 2: 'Problematic',
3: 'Proofread', 4: 'Validated'}
:param expiry: either a number of days or a datetime.timedelta object
:type expiry: int (days), :py:obj:`datetime.timedelta`, False (config)
:return: A tuple containing _proofread_index_ns,
self._proofread_page_ns and self._proofread_levels.
:rtype: Namespace, Namespace, dict
"""
if (not hasattr(self, '_proofread_index_ns')
or not hasattr(self, '_proofread_page_ns')
or not hasattr(self, '_proofread_levels')):
pirequest = self._request(
expiry=pywikibot.config.API_config_expiry
if expiry is False else expiry,
parameters={'action': 'query', 'meta': 'proofreadinfo'}
)
pidata = pirequest.submit()
ns_id = pidata['query']['proofreadnamespaces']['index']['id']
self._proofread_index_ns = self.namespaces[ns_id]
ns_id = pidata['query']['proofreadnamespaces']['page']['id']
self._proofread_page_ns = self.namespaces[ns_id]
self._proofread_levels = {}
for ql in pidata['query']['proofreadqualitylevels']:
self._proofread_levels[ql['id']] = ql['category']
@property
def proofread_index_ns(self):
"""Return Index namespace for the ProofreadPage extension."""
if not hasattr(self, '_proofread_index_ns'):
self._cache_proofreadinfo()
return self._proofread_index_ns
@property
def proofread_page_ns(self):
"""Return Page namespace for the ProofreadPage extension."""
if not hasattr(self, '_proofread_page_ns'):
self._cache_proofreadinfo()
return self._proofread_page_ns
@property
def proofread_levels(self):
"""Return Quality Levels for the ProofreadPage extension."""
if not hasattr(self, '_proofread_levels'):
self._cache_proofreadinfo()
return self._proofread_levels
[docs]
@need_extension('ProofreadPage')
def loadpageurls(self, page: pywikibot.page.BasePage) -> None:
"""Load URLs from api and store in page attributes.
Load URLs to images for a given page in the "Page:" namespace.
No effect for pages in other namespaces.
.. versionadded:: 8.6
.. seealso:: :api:`imageforpage`
"""
title = page.title(with_section=False)
# responsiveimages: server would try to render the other images as well
# let's not load the server unless needed.
prppifpprop = 'filename|size|fullsize'
query = self._generator(api.PropertyGenerator,
type_arg='imageforpage',
titles=title.encode(self.encoding()),
prppifpprop=prppifpprop)
self._update_page(page, query)
[docs]
class GeoDataMixin:
"""APISite mixin for GeoData extension."""
[docs]
@need_extension('GeoData')
def loadcoordinfo(self, page) -> None:
"""Load [[mw:Extension:GeoData]] info."""
title = page.title(with_section=False)
query = self._generator(api.PropertyGenerator,
type_arg='coordinates',
titles=title.encode(self.encoding()),
coprop=['type', 'name', 'dim',
'country', 'region',
'globe'],
coprimary='all')
self._update_page(page, query)
[docs]
class PageImagesMixin:
"""APISite mixin for PageImages extension."""
[docs]
@need_extension('PageImages')
def loadpageimage(self, page) -> None:
"""Load [[mw:Extension:PageImages]] info.
:param page: The page for which to obtain the image
:type page: pywikibot.Page
:raises APIError: PageImages extension is not installed
"""
title = page.title(with_section=False)
query = self._generator(api.PropertyGenerator,
type_arg='pageimages',
titles=title.encode(self.encoding()),
piprop=['name'])
self._update_page(page, query)
[docs]
class GlobalUsageMixin:
"""APISite mixin for Global Usage extension."""
[docs]
@need_extension('Global Usage')
def globalusage(self, page, total=None):
"""Iterate global image usage for a given FilePage.
:param page: the page to return global image usage for.
:type page: pywikibot.FilePage
:param total: iterate no more than this number of pages in total.
:raises TypeError: input page is not a FilePage.
:raises pywikibot.exceptions.SiteDefinitionError: Site could not be
defined for a returned entry in API response.
"""
if not isinstance(page, pywikibot.FilePage):
raise TypeError(f'Page {page} must be a FilePage.')
title = page.title(with_section=False)
args = {'titles': title,
'gufilterlocal': False,
}
query = self._generator(api.PropertyGenerator,
type_arg='globalusage',
guprop=['url', 'pageid', 'namespace'],
total=total, # will set gulimit=total in api,
**args)
for pageitem in query:
if not self.sametitle(pageitem['title'],
page.title(with_section=False)):
raise InconsistentTitleError(page, pageitem['title'])
api.update_page(page, pageitem, query.props)
assert 'globalusage' in pageitem, \
"API globalusage response lacks 'globalusage' key"
for entry in pageitem['globalusage']:
try:
gu_site = pywikibot.Site(url=entry['url'])
except SiteDefinitionError:
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
[docs]
class WikibaseClientMixin:
"""APISite mixin for WikibaseClient extension."""
[docs]
@need_extension('WikibaseClient')
def unconnected_pages(self, total=None):
"""Yield Page objects from Special:UnconnectedPages.
.. warning:: The retrieved pages may be connected in meantime.
:param total: number of pages to return
"""
return self.querypage('UnconnectedPages', total)
[docs]
class LinterMixin:
"""APISite mixin for Linter extension."""
[docs]
@need_extension('Linter')
def linter_pages(self, lint_categories=None, total=None,
namespaces=None, pageids=None, lint_from=None):
"""Return a generator to pages containing linter errors.
:param lint_categories: categories of lint errors
:type lint_categories: an iterable that returns values (str),
or a pipe-separated string of values.
:param total: if not None, yielding this many items in total
:type total: int
:param namespaces: only iterate pages in these namespaces
:type namespaces: iterable of str or Namespace key,
or a single instance of those types. May be a '|' separated
list of namespace identifiers.
:param pageids: only include lint errors from the specified pageids
:type pageids: an iterable that returns pageids (str or int),
or a comma- or pipe-separated string of pageids
(e.g. '945097,1483753, 956608' or '945097|483753|956608')
:param lint_from: Lint ID to start querying from
:type lint_from: str representing digit or integer
:return: pages with Linter errors.
:rtype: typing.Iterable[pywikibot.Page]
"""
query = self._generator(api.ListGenerator, type_arg='linterrors',
total=total, # Will set lntlimit
namespaces=namespaces)
if lint_categories:
if isinstance(lint_categories, str):
lint_categories = lint_categories.split('|')
lint_categories = [p.strip() for p in lint_categories]
query.request['lntcategories'] = '|'.join(lint_categories)
if pageids:
if isinstance(pageids, str):
pageids = pageids.split('|')
pageids = [p.strip() for p in pageids]
# Validate pageids.
pageids = (str(int(p)) for p in pageids if int(p) > 0)
query.request['lntpageid'] = '|'.join(pageids)
if lint_from:
query.request['lntfrom'] = int(lint_from)
for pageitem in query:
page = pywikibot.Page(self, pageitem['title'])
api.update_page(page, pageitem)
yield page
[docs]
class ThanksMixin:
"""APISite mixin for Thanks extension."""
[docs]
@need_extension('Thanks')
def thank_revision(self, revid, source=None):
"""Corresponding method to the 'action=thank' API action.
:param revid: Revision ID for the revision to be thanked.
:type revid: int
:param source: A source for the thanking operation.
:type source: str
:raise APIError: On thanking oneself or other API errors.
:return: The API response.
"""
token = self.tokens['csrf']
req = self.simple_request(action='thank', rev=revid, token=token,
source=source)
data = req.submit()
if data['result']['success'] != 1:
raise APIError('Thanking unsuccessful', '')
return data
[docs]
class ThanksFlowMixin:
"""APISite mixin for Thanks and Structured Discussions extension.
.. deprecated:: 9.4.0
Structured Discussions extension formerly known as Flow
extenstion is not maintained and will be removed. Users are
encouraged to stop using it. (:phab:`T371180`)
.. seealso:: :mod:`flow`
"""
[docs]
@need_extension('Flow')
@need_extension('Thanks')
@deprecated(since='9.4.0')
def thank_post(self, post):
"""Corresponding method to the 'action=flowthank' API action.
:param post: The post to be thanked for.
:type post: Post
:raise APIError: On thanking oneself or other API errors.
:return: The API response.
"""
post_id = post.uuid
token = self.tokens['csrf']
req = self.simple_request(action='flowthank', postid=post_id,
token=token)
data = req.submit()
if data['result']['success'] != 1:
raise APIError('Thanking unsuccessful', '')
return data
[docs]
class FlowMixin:
"""APISite mixin for Structured Discussions extension.
.. deprecated:: 9.4.0
Structured Discussions extension formerly known as Flow
extenstion is not maintained and will be removed. Users are
encouraged to stop using it. (:phab:`T371180`)
.. seealso:: :mod:`flow`
"""
[docs]
@need_extension('Flow')
@deprecated(since='9.4.0')
def load_board(self, page):
"""Retrieve the data for a Flow board.
:param page: A Flow board
:type page: Board
:return: A dict representing the board's metadata.
:rtype: dict
"""
req = self.simple_request(action='flow', page=page,
submodule='view-topiclist', vtllimit=1)
data = req.submit()
return data['flow']['view-topiclist']['result']['topiclist']
[docs]
@need_extension('Flow')
@deprecated(since='9.4.0')
def load_topiclist(self,
page: pywikibot.flow.Board,
*,
content_format: str = 'wikitext',
limit: int = 100,
sortby: str = 'newest',
toconly: bool = False,
offset: pywikibot.Timestamp | str | None = None,
offset_id: str | None = None,
reverse: bool = False,
include_offset: bool = False) -> dict[str, Any]:
"""Retrieve the topiclist of a Flow board.
.. versionchanged:: 8.0
All parameters except *page* are keyword only parameters.
:param page: A Flow board
:param content_format: The content format to request the data in.
must be either 'wikitext', 'html', or 'fixed-html'
:param limit: The number of topics to fetch in each single request.
:param sortby: Algorithm to sort topics by ('newest' or 'updated').
:param toconly: Whether to only include information for the TOC.
:param offset: The timestamp to start at (when sortby is 'updated').
:param offset_id: The topic UUID to start at (when sortby is 'newest').
:param reverse: Whether to reverse the topic ordering.
:param include_offset: Whether to include the offset topic.
:return: A dict representing the board's topiclist.
"""
if offset:
offset = pywikibot.Timestamp.fromtimestampformat(offset)
offset_dir = 'rev' if reverse else 'fwd'
params = {'action': 'flow', 'submodule': 'view-topiclist',
'page': page,
'vtlformat': content_format, 'vtlsortby': sortby,
'vtllimit': limit, 'vtloffset-dir': offset_dir,
'vtloffset': offset, 'vtloffset-id': offset_id,
'vtlinclude-offset': include_offset, 'vtltoconly': toconly}
req = self._request(parameters=params)
data = req.submit()
return data['flow']['view-topiclist']['result']['topiclist']
[docs]
@need_extension('Flow')
@deprecated(since='9.4.0')
def load_topic(self, page, content_format: str):
"""Retrieve the data for a Flow topic.
:param page: A Flow topic
:type page: Topic
:param content_format: The content format to request the data in.
Must ne either 'wikitext', 'html', or 'fixed-html'
:return: A dict representing the topic's data.
:rtype: dict
"""
req = self.simple_request(action='flow', page=page,
submodule='view-topic',
vtformat=content_format)
data = req.submit()
return data['flow']['view-topic']['result']['topic']
[docs]
@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.
:param page: A Flow topic
:type page: Topic
:param post_id: The UUID of the Post
:type post_id: str
:param content_format: The content format used for the returned
content; must be either 'wikitext', 'html', or 'fixed-html'
:return: A dict representing the post data for the given UUID.
:rtype: dict
"""
req = self.simple_request(action='flow', page=page,
submodule='view-post', vppostId=post_id,
vpformat=content_format)
data = req.submit()
return data['flow']['view-post']['result']['topic']
[docs]
@need_right('edit')
@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.
:param page: A Flow board
:type page: Board
:param title: The title of the new topic (must be in plaintext)
:type title: str
:param content: The content of the topic's initial post
:type content: str
:param content_format: The content format of the supplied content
:type content_format: str (either 'wikitext' or 'html')
:return: The metadata of the new topic
:rtype: dict
"""
token = self.tokens['csrf']
params = {'action': 'flow', 'page': page, 'token': token,
'submodule': 'new-topic', 'ntformat': content_format,
'nttopic': title, 'ntcontent': content}
req = self._request(parameters=params, use_get=False)
data = req.submit()
return data['flow']['new-topic']['committed']['topiclist']
[docs]
@need_right('edit')
@need_extension('Flow')
@deprecated(since='9.4.0')
def reply_to_post(self, page, reply_to_uuid: str, content: str,
content_format: str) -> dict:
"""Reply to a post on a Flow topic.
:param page: A Flow topic
:type page: Topic
:param reply_to_uuid: The UUID of the Post to create a reply to
:param content: The content of the reply
:param content_format: The content format used for the supplied
content; must be either 'wikitext' or 'html'
:return: Metadata returned by the API
"""
token = self.tokens['csrf']
params = {'action': 'flow', 'page': page, 'token': token,
'submodule': 'reply', 'repreplyTo': reply_to_uuid,
'repcontent': content, 'repformat': content_format}
req = self._request(parameters=params, use_get=False)
data = req.submit()
return data['flow']['reply']['committed']['topic']
[docs]
@need_right('flow-lock')
@need_extension('Flow')
@deprecated(since='9.4.0')
def lock_topic(self, page, lock, reason):
"""Lock or unlock a Flow topic.
:param page: A Flow topic
:type page: Topic
:param lock: Whether to lock or unlock the topic
:type lock: bool (True corresponds to locking the topic.)
:param reason: The reason to lock or unlock the topic
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
status = 'lock' if lock else 'unlock'
token = self.tokens['csrf']
params = {'action': 'flow', 'page': page, 'token': token,
'submodule': 'lock-topic', 'cotreason': reason,
'cotmoderationState': status}
req = self._request(parameters=params, use_get=False)
data = req.submit()
return data['flow']['lock-topic']['committed']['topic']
[docs]
@need_right('edit')
@need_extension('Flow')
@deprecated(since='9.4.0')
def moderate_topic(self, page, state, reason):
"""Moderate a Flow topic.
:param page: A Flow topic
:type page: Topic
:param state: The new moderation state
:type state: str
:param reason: The reason to moderate the topic
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
token = self.tokens['csrf']
params = {'action': 'flow', 'page': page, 'token': token,
'submodule': 'moderate-topic', 'mtreason': reason,
'mtmoderationState': state}
req = self._request(parameters=params, use_get=False)
data = req.submit()
return data['flow']['moderate-topic']['committed']['topic']
[docs]
@need_extension('Flow')
@deprecated(since='9.4.0')
def summarize_topic(self, page, summary):
"""Add summary to Flow topic.
:param page: A Flow topic
:type page: Topic
:param summary: The text of the summary
:type symmary: str
:return: Metadata returned by the API
:rtype: dict
"""
token = self.tokens['csrf']
params = {'action': 'flow', 'page': page, 'token': token,
'submodule': 'edit-topic-summary', 'etssummary': summary,
'etsformat': 'wikitext'}
if 'summary' in page.root._current_revision:
params['etsprev_revision'] = page.root._current_revision[
'summary']['revision']['revisionId']
req = self._request(parameters=params, use_get=False)
data = req.submit()
return data['flow']['edit-topic-summary']['committed']['topicsummary']
[docs]
@need_right('flow-delete')
@need_extension('Flow')
@deprecated(since='9.4.0')
def delete_topic(self, page, reason):
"""Delete a Flow topic.
:param page: A Flow topic
:type page: Topic
:param reason: The reason to delete the topic
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
return self.moderate_topic(page, 'delete', reason)
[docs]
@need_right('flow-hide')
@need_extension('Flow')
@deprecated(since='9.4.0')
def hide_topic(self, page, reason):
"""Hide a Flow topic.
:param page: A Flow topic
:type page: Topic
:param reason: The reason to hide the topic
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
return self.moderate_topic(page, 'hide', reason)
[docs]
@need_right('flow-suppress')
@need_extension('Flow')
@deprecated(since='9.4.0')
def suppress_topic(self, page, reason):
"""Suppress a Flow topic.
:param page: A Flow topic
:type page: Topic
:param reason: The reason to suppress the topic
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
return self.moderate_topic(page, 'suppress', reason)
[docs]
@need_right('edit')
@need_extension('Flow')
@deprecated(since='9.4.0')
def restore_topic(self, page, reason):
"""Restore a Flow topic.
:param page: A Flow topic
:type page: Topic
:param reason: The reason to restore the topic
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
return self.moderate_topic(page, 'restore', reason)
[docs]
@need_right('edit')
@need_extension('Flow')
@deprecated(since='9.4.0')
def moderate_post(self, post, state, reason):
"""Moderate a Flow post.
:param post: A Flow post
:type post: Post
:param state: The new moderation state
:type state: str
:param reason: The reason to moderate the topic
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
page = post.page
uuid = post.uuid
token = self.tokens['csrf']
params = {'action': 'flow', 'page': page, 'token': token,
'submodule': 'moderate-post', 'mpreason': reason,
'mpmoderationState': state, 'mppostId': uuid}
req = self._request(parameters=params, use_get=False)
data = req.submit()
return data['flow']['moderate-post']['committed']['topic']
[docs]
@need_right('flow-delete')
@need_extension('Flow')
@deprecated(since='9.4.0')
def delete_post(self, post, reason):
"""Delete a Flow post.
:param post: A Flow post
:type post: Post
:param reason: The reason to delete the post
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
return self.moderate_post(post, 'delete', reason)
[docs]
@need_right('flow-hide')
@need_extension('Flow')
@deprecated(since='9.4.0')
def hide_post(self, post, reason):
"""Hide a Flow post.
:param post: A Flow post
:type post: Post
:param reason: The reason to hide the post
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
return self.moderate_post(post, 'hide', reason)
[docs]
@need_right('flow-suppress')
@need_extension('Flow')
@deprecated(since='9.4.0')
def suppress_post(self, post, reason):
"""Suppress a Flow post.
:param post: A Flow post
:type post: Post
:param reason: The reason to suppress the post
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
return self.moderate_post(post, 'suppress', reason)
[docs]
@need_right('edit')
@need_extension('Flow')
@deprecated(since='9.4.0')
def restore_post(self, post, reason):
"""Restore a Flow post.
:param post: A Flow post
:type post: Post
:param reason: The reason to restore the post
:type reason: str
:return: Metadata returned by the API
:rtype: dict
"""
return self.moderate_post(post, 'restore', reason)
[docs]
class UrlShortenerMixin:
"""APISite mixin for UrlShortener extension."""
[docs]
@need_extension('UrlShortener')
def create_short_link(self, url):
"""Return a shortened link.
Note that on Wikimedia wikis only metawiki supports this action,
and this wiki can process links to all WM domains.
:param url: The link to reduce, with propotol prefix.
:type url: str
:return: The reduced link, without protocol prefix.
:rtype: str
"""
req = self.simple_request(action='shortenurl', url=url)
data = req.submit()
return data['shortenurl']['shorturl']