Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • w/shared/mwclient
1 result
Show changes
from http.cookiejar import CookieJar
from typing import Union, Tuple, Mapping
Cookies = Union[Mapping[str, str], CookieJar]
Namespace = Union[str, int]
VersionTuple = Tuple[Union[int, str], ...]
...@@ -2,6 +2,8 @@ import json ...@@ -2,6 +2,8 @@ import json
import logging import logging
import warnings import warnings
from collections import OrderedDict from collections import OrderedDict
from typing import Optional, Callable, Union, Mapping, Any, MutableMapping, List, Dict, \
Tuple, cast, Iterable, BinaryIO, Iterator
import requests import requests
from requests.auth import AuthBase, HTTPBasicAuth from requests.auth import AuthBase, HTTPBasicAuth
...@@ -9,15 +11,15 @@ from requests_oauthlib import OAuth1 ...@@ -9,15 +11,15 @@ from requests_oauthlib import OAuth1
import mwclient.errors as errors import mwclient.errors as errors
import mwclient.listing as listing import mwclient.listing as listing
from mwclient.sleep import Sleepers from mwclient._types import Cookies, Namespace, VersionTuple
from mwclient.util import parse_timestamp, read_in_chunks from mwclient.sleep import Sleeper, Sleepers
from mwclient.util import parse_timestamp, read_in_chunks, handle_limit
__version__ = '0.10.1' __version__ = '0.11.0'
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
USER_AGENT = 'mwclient/{} ({})'.format(__version__, USER_AGENT = f'mwclient/{__version__} (https://github.com/mwclient/mwclient)'
'https://github.com/mwclient/mwclient')
class Site: class Site:
...@@ -29,53 +31,50 @@ class Site: ...@@ -29,53 +31,50 @@ class Site:
>>> wikia_site = mwclient.Site('vim.wikia.com', path='/') >>> wikia_site = mwclient.Site('vim.wikia.com', path='/')
Args: Args:
host (str): The hostname of a MediaWiki instance. Must not include a host: The hostname of a MediaWiki instance. Must not include a scheme
scheme (e.g. `https://`) - use the `scheme` argument instead. (e.g. `https://`) - use the `scheme` argument instead.
path (str): The instances script path (where the `index.php` and `api.php` scripts path: The instances script path (where the `index.php` and `api.php` scripts are
are located). Must contain a trailing slash (`/`). Defaults to `/w/`. located). Must contain a trailing slash (`/`). Defaults to `/w/`.
ext (str): The file extension used by the MediaWiki API scripts. Defaults to ext: The file extension used by the MediaWiki API scripts. Defaults to `.php`.
`.php`. pool: A preexisting :class:`~requests.Session` to be used when executing API
pool (requests.Session): A preexisting :class:`~requests.Session` to be used when requests.
executing API requests. retry_timeout: The number of seconds to sleep for each past retry of a failing API
retry_timeout (int): The number of seconds to sleep for each past retry of a request. Defaults to `30`.
failing API request. Defaults to `30`. max_retries: The maximum number of retries to perform for failing API requests.
max_retries (int): The maximum number of retries to perform for failing API Defaults to `25`.
requests. Defaults to `25`. wait_callback: A callback function to be executed for each failing API request.
wait_callback (Callable): A callback function to be executed for each failing clients_useragent: A prefix to be added to the default mwclient user-agent. Should
API request. follow the pattern `'{tool_name}/{tool_version} ({contact})'`. Check the
clients_useragent (str): A prefix to be added to the default mwclient user-agent. `User-Agent policy <https://meta.wikimedia.org/wiki/User-Agent_policy>`_
Should follow the pattern `'{tool_name}/{tool_version} ({contact})'`. Check
the `User-Agent policy <https://meta.wikimedia.org/wiki/User-Agent_policy>`_
for more information. for more information.
max_lag (int): A `maxlag` parameter to be used in `index.php` calls. Consult the max_lag: A `maxlag` parameter to be used in `index.php` calls. Consult the
`documentation <https://www.mediawiki.org/wiki/Manual:Maxlag_parameter>`_ for `documentation <https://www.mediawiki.org/wiki/Manual:Maxlag_parameter>`_ for
more information. Defaults to `3`. more information. Defaults to `3`.
compress (bool): Whether to request and accept gzip compressed API responses. compress: Whether to request and accept gzip compressed API responses. Defaults to
Defaults to `True`. `True`.
force_login (bool): Whether to require authentication when editing pages. Set to force_login: Whether to require authentication when editing pages. Set to `False`
`False` to allow unauthenticated edits. Defaults to `True`. to allow unauthenticated edits. Defaults to `True`.
do_init (bool): Whether to automatically initialize the :py:class:`Site` on do_init: Whether to automatically initialize the :py:class:`Site` on
initialization. When set to `False`, the :py:class:`Site` must be initialized initialization. When set to `False`, the :py:class:`Site` must be initialized
manually using the :py:meth:`.site_init` method. Defaults to `True`. manually using the :py:meth:`.site_init` method. Defaults to `True`.
httpauth (Union[tuple[basestring, basestring], requests.auth.AuthBase]): An httpauth: An authentication method to be used when making API requests. This can
authentication method to be used when making API requests. This can be either be either an authentication object as provided by the :py:mod:`requests`
an authentication object as provided by the :py:mod:`requests` library, or a library, or a tuple in the form `{username, password}`. Usernames and
tuple in the form `{username, password}`. Usernames and passwords provided as passwords provided as text strings are encoded as UTF-8. If dealing with a
text strings are encoded as UTF-8. If dealing with a server that cannot server that cannot handle UTF-8, please provide the username and password
handle UTF-8, please provide the username and password already encoded with already encoded with the appropriate encoding.
the appropriate encoding. connection_options: Additional arguments to be passed to the
reqs (Dict[str, Any]): Additional arguments to be passed to the
:py:meth:`requests.Session.request` method when performing API calls. If the :py:meth:`requests.Session.request` method when performing API calls. If the
`timeout` key is empty, a default timeout of 30 seconds is added. `timeout` key is empty, a default timeout of 30 seconds is added.
consumer_token (str): OAuth1 consumer key for owner-only consumers. consumer_token: OAuth1 consumer key for owner-only consumers.
consumer_secret (str): OAuth1 consumer secret for owner-only consumers. consumer_secret: OAuth1 consumer secret for owner-only consumers.
access_token (str): OAuth1 access key for owner-only consumers. access_token: OAuth1 access key for owner-only consumers.
access_secret (str): OAuth1 access secret for owner-only consumers. access_secret: OAuth1 access secret for owner-only consumers.
client_certificate (Union[str, tuple[str, str]]): A client certificate to be added client_certificate: A client certificate to be added
to the session. to the session.
custom_headers (Dict[str, str]): A dictionary of custom headers to be added to all custom_headers: A dictionary of custom headers to be added to all
API requests. API requests.
scheme (str): The URI scheme to use. This should be either `http` or `https` in scheme: The URI scheme to use. This should be either `http` or `https` in
most cases. Defaults to `https`. most cases. Defaults to `https`.
Raises: Raises:
...@@ -91,21 +90,56 @@ class Site: ...@@ -91,21 +90,56 @@ class Site:
} }
api_limit = 500 api_limit = 500
def __init__(self, host, path='/w/', ext='.php', pool=None, retry_timeout=30, def __init__(
max_retries=25, wait_callback=lambda *x: None, clients_useragent=None, self,
max_lag=3, compress=True, force_login=True, do_init=True, httpauth=None, host: str,
reqs=None, consumer_token=None, consumer_secret=None, access_token=None, path: str = '/w/',
access_secret=None, client_certificate=None, custom_headers=None, ext: str = '.php',
scheme='https'): pool: Optional[requests.Session] = None,
retry_timeout: int = 30,
max_retries: int = 25,
wait_callback: Callable[['Sleeper', int, Optional[Any]], Any] = lambda *x: None,
clients_useragent: Optional[str] = None,
max_lag: int = 3,
compress: bool = True,
force_login: bool = True,
do_init: bool = True,
httpauth: Union[
Tuple[Union[str, bytes], Union[str, bytes]],
requests.auth.AuthBase,
List[Union[str, bytes]],
None,
] = None,
connection_options: Optional[MutableMapping[str, Any]] = None,
consumer_token: Optional[str] = None,
consumer_secret: Optional[str] = None,
access_token: Optional[str] = None,
access_secret: Optional[str] = None,
client_certificate: Optional[Union[str, Tuple[str, str]]] = None,
custom_headers: Optional[Mapping[str, str]] = None,
scheme: str = 'https',
reqs: Optional[MutableMapping[str, Any]] = None
) -> None:
# Setup member variables # Setup member variables
self.host = host self.host = host
self.path = path self.path = path
self.ext = ext self.ext = ext
self.credentials = None self.credentials = None # type: Optional[Tuple[str, str, Optional[str]]]
self.compress = compress self.compress = compress
self.max_lag = str(max_lag) self.max_lag = str(max_lag)
self.force_login = force_login self.force_login = force_login
self.requests = reqs or {} self.logged_in = False
if reqs and connection_options:
raise ValueError(
"reqs is a deprecated alias of connection_options. Do not specify both."
)
if reqs:
warnings.warn(
"reqs is deprecated in mwclient 1.0.0. Use connection_options instead",
DeprecationWarning
)
connection_options = reqs
self.requests = connection_options or {}
self.scheme = scheme self.scheme = scheme
if 'timeout' not in self.requests: if 'timeout' not in self.requests:
self.requests['timeout'] = 30 # seconds self.requests['timeout'] = 30 # seconds
...@@ -129,19 +163,18 @@ class Site: ...@@ -129,19 +163,18 @@ class Site:
self.sleepers = Sleepers(max_retries, retry_timeout, wait_callback) self.sleepers = Sleepers(max_retries, retry_timeout, wait_callback)
# Site properties # Site properties
self.blocked = False # Whether current user is blocked self.blocked = False # type: Union[Tuple[str, str], bool] # Is user blocked?
self.hasmsg = False # Whether current user has new messages self.hasmsg = False # Whether current user has new messages
self.groups = [] # Groups current user belongs to self.groups = [] # type: List[str] # Groups current user is in
self.rights = [] # Rights current user has self.rights = [] # type: List[str] # Rights current user has
self.tokens = {} # Edit tokens of the current user self.tokens = {} # type: Dict[str, str] # Edit tokens of the current user
self.version = None self.version = None # type: Optional[VersionTuple]
self.namespaces = self.default_namespaces self.namespaces = self.default_namespaces # type: Dict[int, str]
self.writeapi = False
# Setup connection # Setup connection
if pool is None: if pool is None:
self.connection = requests.Session() self.connection = requests.Session() # type: requests.Session
self.connection.auth = auth self.connection.auth = auth
if client_certificate: if client_certificate:
self.connection.cert = client_certificate self.connection.cert = client_certificate
...@@ -185,7 +218,7 @@ class Site: ...@@ -185,7 +218,7 @@ class Site:
if e.args[0] not in {'unknown_action', 'readapidenied'}: if e.args[0] not in {'unknown_action', 'readapidenied'}:
raise raise
def site_init(self): def site_init(self) -> None:
"""Populates the object with information about the current user and site. This is """Populates the object with information about the current user and site. This is
done automatically when creating the object, unless explicitly disabled using the done automatically when creating the object, unless explicitly disabled using the
`do_init=False` constructor argument.""" `do_init=False` constructor argument."""
...@@ -209,7 +242,6 @@ class Site: ...@@ -209,7 +242,6 @@ class Site:
namespace['id']: namespace.get('*', '') namespace['id']: namespace.get('*', '')
for namespace in meta['query']['namespaces'].values() for namespace in meta['query']['namespaces'].values()
} }
self.writeapi = 'writeapi' in self.site
self.version = self.version_tuple_from_generator(self.site['generator']) self.version = self.version_tuple_from_generator(self.site['generator'])
...@@ -224,7 +256,9 @@ class Site: ...@@ -224,7 +256,9 @@ class Site:
self.initialized = True self.initialized = True
@staticmethod @staticmethod
def version_tuple_from_generator(string, prefix='MediaWiki '): def version_tuple_from_generator(
string: str, prefix: str = 'MediaWiki '
) -> VersionTuple:
"""Return a version tuple from a MediaWiki Generator string. """Return a version tuple from a MediaWiki Generator string.
Example: Example:
...@@ -232,39 +266,57 @@ class Site: ...@@ -232,39 +266,57 @@ class Site:
(1, 5, 1) (1, 5, 1)
Args: Args:
string (str): The MediaWiki Generator string. string: The MediaWiki Generator string.
prefix (str): The expected prefix of the string. prefix: The expected prefix of the string.
Returns: Returns:
A tuple containing the individual elements of the given version number. tuple[int, int, Union[int, str]...]: The version tuple.
""" """
if not string.startswith(prefix): if not string.startswith(prefix):
raise errors.MediaWikiVersionError('Unknown generator {}'.format(string)) raise errors.MediaWikiVersionError(f'Unknown generator {string}')
version = string[len(prefix):]
version = string[len(prefix):].split('.') def _split_version(version: str) -> Iterator[str]:
"""Split a version string into segments.
def split_num(s): Args:
"""Split the string on the first non-digit character. version (str): The version string (without the prefix).
Returns: Yields:
A tuple of the digit part as int and, if available, str: The individual segments of the version string.
the rest of the string.
""" """
i = 0 current_segment = ''
while i < len(s): for curr_char in version:
if s[i] < '0' or s[i] > '9': if curr_char in "-+_.":
break yield current_segment
i += 1 current_segment = ''
if s[i:]: elif current_segment and (
return (int(s[:i]), s[i:], ) (current_segment[-1].isdigit() and curr_char.isalpha())
else: or (current_segment[-1].isalpha() and curr_char.isdigit())
return (int(s[:i]), ) ):
yield current_segment
current_segment = curr_char
else:
current_segment += curr_char
yield current_segment
version_tuple = sum((split_num(s) for s in version), ()) version_tuple = tuple(
int(segment) if segment.isdigit() else segment
for segment in _split_version(version)
) # type: Tuple[Union[int, str], ...]
if len(version_tuple) < 2: if len(version_tuple) < 2:
raise errors.MediaWikiVersionError('Unknown MediaWiki {}' raise errors.MediaWikiVersionError(f'Unknown MediaWiki {".".join(version)}')
.format('.'.join(version)))
# Ensure the major and minor version components are integers.
# Non-integer values for these components are not supported and will
# cause comparison issues.
if not all(isinstance(segment, int) for segment in version_tuple[:2]):
raise errors.MediaWikiVersionError(
f'Unknown MediaWiki {".".join(version)}. '
'Major and minor version must be integers.'
)
return version_tuple return version_tuple
...@@ -276,45 +328,66 @@ class Site: ...@@ -276,45 +328,66 @@ class Site:
-1: 'Special', -2: 'Media' -1: 'Special', -2: 'Media'
} }
def __repr__(self): def __repr__(self) -> str:
return "<%s object '%s%s'>" % (self.__class__.__name__, self.host, self.path) return f"<{self.__class__.__name__} object '{self.host}{self.path}'>"
def get(self, action, *args, **kwargs): def get(self, action: str, *args: Tuple[str, Any], **kwargs: Any) -> Dict[str, Any]:
"""Perform a generic API call using GET. """Perform a generic API call using GET.
This is just a shorthand for calling api() with http_method='GET'. This is just a shorthand for calling api() with http_method='GET'.
All arguments will be passed on. All arguments will be passed on.
Args: Args:
action (str): The MediaWiki API action to be performed. action: The MediaWiki API action to be performed.
*args: Tupled key-value pairs to be passed to the `api.php` script
as data. In most cases, it is preferable to pass these as
keyword arguments instead. This can be useful when the
parameter name is a reserved Python keyword (e.g. `from`).
**kwargs: Arguments to be passed to the API call.
Returns: Returns:
The raw response from the API call, as a dictionary. The raw response from the API call, as a dictionary.
""" """
return self.api(action, 'GET', *args, **kwargs) return self.api(action, 'GET', *args, **kwargs)
def post(self, action, *args, **kwargs): def post(self, action: str, *args: Tuple[str, Any], **kwargs: Any) -> Dict[str, Any]:
"""Perform a generic API call using POST. """Perform a generic API call using POST.
This is just a shorthand for calling api() with http_method='POST'. This is just a shorthand for calling api() with http_method='POST'.
All arguments will be passed on. All arguments will be passed on.
Args: Args:
action (str): The MediaWiki API action to be performed. action: The MediaWiki API action to be performed.
*args: Tupled key-value pairs to be passed to the `api.php` script
as data. In most cases, it is preferable to pass these as
keyword arguments instead. This can be useful when the
parameter name is a reserved Python keyword (e.g. `from`).
**kwargs: Arguments to be passed to the API call.
Returns: Returns:
The raw response from the API call, as a dictionary. The raw response from the API call, as a dictionary.
""" """
return self.api(action, 'POST', *args, **kwargs) return self.api(action, 'POST', *args, **kwargs)
def api(self, action, http_method='POST', *args, **kwargs): def api(
self,
action: str,
http_method: str = 'POST',
*args: Tuple[str, Any],
**kwargs: Any
) -> Dict[str, Any]:
"""Perform a generic API call and handle errors. """Perform a generic API call and handle errors.
All arguments will be passed on. All arguments will be passed on.
Args: Args:
action (str): The MediaWiki API action to be performed. action: The MediaWiki API action to be performed.
http_method (str): The HTTP method to use. http_method: The HTTP method to use.
*args: Tupled key-value pairs to be passed to the `api.php` script
as data. In most cases, it is preferable to pass these as
keyword arguments instead. This can be useful when the
parameter name is a reserved Python keyword (e.g. `from`).
**kwargs: Arguments to be passed to the API call.
Example: Example:
To get coordinates from the GeoData MediaWiki extension at English Wikipedia: To get coordinates from the GeoData MediaWiki extension at English Wikipedia:
...@@ -323,9 +396,10 @@ class Site: ...@@ -323,9 +396,10 @@ class Site:
>>> result = site.api('query', prop='coordinates', titles='Oslo|Copenhagen') >>> result = site.api('query', prop='coordinates', titles='Oslo|Copenhagen')
>>> for page in result['query']['pages'].values(): >>> for page in result['query']['pages'].values():
... if 'coordinates' in page: ... if 'coordinates' in page:
... print('{} {} {}'.format(page['title'], ... title = page['title']
... page['coordinates'][0]['lat'], ... lat = page['coordinates'][0]['lat']
... page['coordinates'][0]['lon'])) ... lon = page['coordinates'][0]['lon']
... print(f'{title} {lat} {lon}')
Oslo 59.95 10.75 Oslo 59.95 10.75
Copenhagen 55.6761 12.5683 Copenhagen 55.6761 12.5683
...@@ -334,6 +408,8 @@ class Site: ...@@ -334,6 +408,8 @@ class Site:
""" """
kwargs.update(args) kwargs.update(args)
# this enables new-style continuation in mediawiki 1.21
# through 1.25, can be dropped when we bump baseline to 1.26
if action == 'query' and 'continue' not in kwargs: if action == 'query' and 'continue' not in kwargs:
kwargs['continue'] = '' kwargs['continue'] = ''
if action == 'query': if action == 'query':
...@@ -355,16 +431,20 @@ class Site: ...@@ -355,16 +431,20 @@ class Site:
if self.handle_api_result(info, sleeper=sleeper): if self.handle_api_result(info, sleeper=sleeper):
return info return info
def handle_api_result(self, info, kwargs=None, sleeper=None): def handle_api_result(
self,
info: Mapping[str, Any],
kwargs: Optional[Mapping[str, Any]] = None,
sleeper: Optional['Sleeper'] = None
) -> bool:
"""Checks the given API response, raising an appropriate exception or sleeping if """Checks the given API response, raising an appropriate exception or sleeping if
necessary. necessary.
Args: Args:
info (dict): The API result. info: The API result.
kwargs (dict): Additional arguments to be passed when raising an kwargs: Additional arguments to be passed when raising an
:class:`errors.APIError`. :class:`errors.APIError`.
sleeper (sleep.Sleeper): A :class:`~sleep.Sleeper` instance to use when sleeper: A :class:`~sleep.Sleeper` instance to use when sleeping.
sleeping.
Returns: Returns:
`False` if the given API response contains an exception, else `True`. `False` if the given API response contains an exception, else `True`.
...@@ -376,13 +456,14 @@ class Site: ...@@ -376,13 +456,14 @@ class Site:
try: try:
userinfo = info['query']['userinfo'] userinfo = info['query']['userinfo']
except KeyError: except KeyError:
userinfo = () userinfo = {}
if 'blockedby' in userinfo: if 'blockedby' in userinfo:
self.blocked = (userinfo['blockedby'], userinfo.get('blockreason', '')) self.blocked = (userinfo['blockedby'], userinfo.get('blockreason', ''))
else: else:
self.blocked = False self.blocked = False
self.hasmsg = 'messages' in userinfo self.hasmsg = 'messages' in userinfo
self.logged_in = 'anon' not in userinfo if userinfo:
self.logged_in = 'anon' not in userinfo
if 'warnings' in info: if 'warnings' in info:
for module, warning in info['warnings'].items(): for module, warning in info['warnings'].items():
if '*' in warning: if '*' in warning:
...@@ -416,7 +497,7 @@ class Site: ...@@ -416,7 +497,7 @@ class Site:
return True return True
@staticmethod @staticmethod
def _query_string(*args, **kwargs): def _query_string(*args: Tuple[str, Any], **kwargs: Any) -> Dict[str, Any]:
kwargs.update(args) kwargs.update(args)
qs1 = [ qs1 = [
(k, v) for k, v in kwargs.items() if k not in {'wpEditToken', 'token'} (k, v) for k, v in kwargs.items() if k not in {'wpEditToken', 'token'}
...@@ -426,7 +507,14 @@ class Site: ...@@ -426,7 +507,14 @@ class Site:
] ]
return OrderedDict(qs1 + qs2) return OrderedDict(qs1 + qs2)
def raw_call(self, script, data, files=None, retry_on_error=True, http_method='POST'): def raw_call(
self,
script: str,
data: Mapping[str, Any],
files: Optional[Mapping[str, Union[BinaryIO, Tuple[str, BinaryIO]]]] = None,
retry_on_error: bool = True,
http_method: str = 'POST'
) -> str:
""" """
Perform a generic request and return the raw text. Perform a generic request and return the raw text.
...@@ -439,11 +527,11 @@ class Site: ...@@ -439,11 +527,11 @@ class Site:
HTTP responses. HTTP responses.
Args: Args:
script (str): Script name, usually 'api'. script: Script name, usually 'api'.
data (dict): Post data data: Post data
files (dict): Files to upload files: Files to upload
retry_on_error (bool): Retry on connection error retry_on_error: Retry on connection error
http_method (str): The HTTP method, defaults to 'POST' http_method: The HTTP method, defaults to 'POST'
Returns: Returns:
The raw text response. The raw text response.
...@@ -464,22 +552,20 @@ class Site: ...@@ -464,22 +552,20 @@ class Site:
scheme = self.scheme scheme = self.scheme
host = self.host host = self.host
if isinstance(host, (list, tuple)): if isinstance(host, (list, tuple)): # type: ignore[unreachable]
warnings.warn( warnings.warn( # type: ignore[unreachable]
'Specifying host as a tuple is deprecated as of mwclient 0.10.1. ' 'Specifying host as a tuple is deprecated as of mwclient 0.10.1. '
+ 'Please use the new scheme argument instead.', + 'Please use the new scheme argument instead.',
DeprecationWarning DeprecationWarning
) )
scheme, host = host scheme, host = host
url = '{scheme}://{host}{path}{script}{ext}'.format(scheme=scheme, host=host, url = f'{scheme}://{host}{self.path}{script}{self.ext}'
path=self.path, script=script,
ext=self.ext)
while True: while True:
toraise = None toraise = None # type: Optional[Union[requests.RequestException, str]]
wait_time = 0 wait_time = 0
args = {'files': files, 'headers': headers} args = {'files': files, 'headers': headers} # type: Dict[str, Any]
for k, v in self.requests.items(): for k, v in self.requests.items():
args[k] = v args[k] = v
if http_method == 'GET': if http_method == 'GET':
...@@ -490,21 +576,24 @@ class Site: ...@@ -490,21 +576,24 @@ class Site:
try: try:
stream = self.connection.request(http_method, url, **args) stream = self.connection.request(http_method, url, **args)
if stream.headers.get('x-database-lag'): if stream.headers.get('x-database-lag'):
wait_time = int(stream.headers.get('retry-after')) wait_time = int(
stream.headers.get('retry-after') # type: ignore[arg-type]
)
log.warning('Database lag exceeds max lag. ' log.warning('Database lag exceeds max lag. '
'Waiting for {} seconds'.format(wait_time)) 'Waiting for %d seconds', wait_time)
# fall through to the sleep # fall through to the sleep
elif stream.status_code == 200: elif stream.status_code == 200:
return stream.text return stream.text
elif stream.status_code < 500 or stream.status_code > 599: elif (
(stream.status_code < 500 or stream.status_code > 599)
and stream.status_code != 429 # 429 Too Many Requests is retryable
):
stream.raise_for_status() stream.raise_for_status()
else: else:
if not retry_on_error: if not retry_on_error:
stream.raise_for_status() stream.raise_for_status()
log.warning('Received {status} response: {text}. ' log.warning('Received %d response: %s. Retrying in a moment.',
'Retrying in a moment.' stream.status_code, stream.text)
.format(status=stream.status_code,
text=stream.text))
toraise = "stream" toraise = "stream"
# fall through to the sleep # fall through to the sleep
...@@ -527,21 +616,30 @@ class Site: ...@@ -527,21 +616,30 @@ class Site:
except errors.MaximumRetriesExceeded: except errors.MaximumRetriesExceeded:
if toraise == "stream": if toraise == "stream":
stream.raise_for_status() stream.raise_for_status()
elif toraise: elif toraise and isinstance(toraise, BaseException):
raise toraise raise toraise
else: else:
raise raise
def raw_api(self, action, http_method='POST', retry_on_error=True, *args, **kwargs): def raw_api(
self,
action: str,
http_method: str = 'POST',
retry_on_error: bool = True,
*args: Tuple[str, Any],
**kwargs: Any
) -> Dict[str, Any]:
"""Send a call to the API. """Send a call to the API.
Args: Args:
action (str): The MediaWiki API action to perform. action: The MediaWiki API action to perform.
http_method (str): The HTTP method to use in the request. http_method: The HTTP method to use in the request.
retry_on_error (bool): Whether to retry API call on connection errors. retry_on_error: Whether to retry API call on connection errors.
*args (Tuple[str, Any]): Arguments to be passed to the `api.php` script as *args: Tupled key-value pairs to be passed to the `api.php` script
data. as data. In most cases, it is preferable to pass these as
**kwargs (Any): Arguments to be passed to the `api.php` script as data. keyword arguments instead. This can be useful when the
parameter name is a reserved Python keyword (e.g. `from`).
**kwargs: Arguments to be passed to the `api.php` script as data.
Returns: Returns:
The API response. The API response.
...@@ -564,21 +662,29 @@ class Site: ...@@ -564,21 +662,29 @@ class Site:
http_method=http_method) http_method=http_method)
try: try:
return json.loads(res, object_pairs_hook=OrderedDict) return cast(Dict[str, Any], json.loads(res, object_pairs_hook=OrderedDict))
except ValueError: except ValueError:
if res.startswith('MediaWiki API is not enabled for this site.'): if res.startswith('MediaWiki API is not enabled for this site.'):
raise errors.APIDisabledError raise errors.APIDisabledError
raise errors.InvalidResponse(res) raise errors.InvalidResponse(res)
def raw_index(self, action, http_method='POST', *args, **kwargs): def raw_index(
self,
action: str,
http_method: str = 'POST',
*args: Tuple[str, Any],
**kwargs: Any
) -> str:
"""Sends a call to index.php rather than the API. """Sends a call to index.php rather than the API.
Args: Args:
action (str): The MediaWiki API action to perform. action: The MediaWiki API action to perform.
http_method (str): The HTTP method to use in the request. http_method: The HTTP method to use in the request.
*args (Tuple[str, Any]): Arguments to be passed to the `index.php` script as *args: Tupled key-value pairs to be passed to the `index.php`
data. script as data. In most cases, it is preferable to pass these
**kwargs (Any): Arguments to be passed to the `index.php` script as data. as keyword arguments instead. This can be useful when the
parameter name is a reserved Python keyword (e.g. `from`).
**kwargs: Arguments to be passed to the `index.php` script as data.
Returns: Returns:
The API response. The API response.
...@@ -597,20 +703,26 @@ class Site: ...@@ -597,20 +703,26 @@ class Site:
data = self._query_string(*args, **kwargs) data = self._query_string(*args, **kwargs)
return self.raw_call('index', data, http_method=http_method) return self.raw_call('index', data, http_method=http_method)
def require(self, major, minor, revision=None, raise_error=True): def require(
self,
major: int,
minor: int,
revision: Optional[int] = None,
raise_error: bool = True
) -> Optional[bool]:
"""Check whether the current wiki matches the required version. """Check whether the current wiki matches the required version.
Args: Args:
major (int): The required major version. major: The required major version.
minor (int): The required minor version. minor: The required minor version.
revision (int): The required revision. revision: The required revision.
raise_error (bool): Whether to throw an error if the version of the current raise_error: Whether to throw an error if the version of the current wiki is
wiki is below the required version. Defaults to `True`. below the required version. Defaults to `True`.
Returns: Returns:
`False` if the version of the current wiki is below the required version, else `False` if the version of the current wiki is below the required version, else
`True`. If either `raise_error=True` or the site is uninitialized and `True`. If `raise_error` is `False` and the version is below the required
`raise_error=None` then nothing is returned. version, `None` is returned.
Raises: Raises:
errors.MediaWikiVersionError: The current wiki is below the required version errors.MediaWikiVersionError: The current wiki is below the required version
...@@ -625,27 +737,34 @@ class Site: ...@@ -625,27 +737,34 @@ class Site:
""" """
if self.version is None: if self.version is None:
if raise_error is None: if raise_error is None:
return warnings.warn( # type: ignore[unreachable]
# FIXME: Replace this with a specific error 'Passing raise_error=None to require is deprecated and will be '
raise RuntimeError('Site %s has not yet been initialized' % repr(self)) 'removed in a future version. Use raise_error=False instead.',
DeprecationWarning
)
return None
elif raise_error is False:
return None
else:
# FIXME: Replace this with a specific error
raise RuntimeError(f'Site {repr(self)} has not yet been initialized')
if revision is None: if revision is None:
if self.version[:2] >= (major, minor): if self.version[:2] >= (major, minor):
return True return True
elif raise_error: elif raise_error:
raise errors.MediaWikiVersionError( raise errors.MediaWikiVersionError(
'Requires version {required[0]}.{required[1]}, ' f'Requires version {major}.{minor}, '
'current version is {current[0]}.{current[1]}' f'current version is {self.version[0]}.{self.version[1]}')
.format(required=(major, minor),
current=(self.version[:2]))
)
else: else:
return False return False
else: else:
raise NotImplementedError raise NotImplementedError
# Actions # Actions
def email(self, user, text, subject, cc=False): def email(
self, user: str, text: str, subject: str, cc: bool = False
) -> Dict[str, Any]:
""" """
Send email to a specified user on the wiki. Send email to a specified user on the wiki.
...@@ -654,11 +773,13 @@ class Site: ...@@ -654,11 +773,13 @@ class Site:
... except mwclient.errors.NoSpecifiedEmail: ... except mwclient.errors.NoSpecifiedEmail:
... print('User does not accept email, or has no email address.') ... print('User does not accept email, or has no email address.')
API doc: https://www.mediawiki.org/wiki/API:Email
Args: Args:
user (str): User name of the recipient user: Username of the recipient
text (str): Body of the email text: Body of the email
subject (str): Subject of the email subject: Subject of the email
cc (bool): True to send a copy of the email to yourself (default is False) cc: True to send a copy of the email to yourself (default is False)
Returns: Returns:
Dictionary of the JSON response Dictionary of the JSON response
...@@ -676,11 +797,17 @@ class Site: ...@@ -676,11 +797,17 @@ class Site:
except errors.APIError as e: except errors.APIError as e:
if e.args[0] == 'noemail': if e.args[0] == 'noemail':
raise errors.NoSpecifiedEmail(user, e.args[1]) raise errors.NoSpecifiedEmail(user, e.args[1])
raise errors.EmailError(*e) raise errors.EmailError(*e) # type: ignore[misc]
return info return info
def login(self, username=None, password=None, cookies=None, domain=None): def login(
self,
username: Optional[str] = None,
password: Optional[str] = None,
cookies: Optional[Cookies] = None,
domain: Optional[str] = None
) -> None:
""" """
Login to the wiki using a username and bot password. The method returns Login to the wiki using a username and bot password. The method returns
nothing if the login was successful, but raises and error if it was not. nothing if the login was successful, but raises and error if it was not.
...@@ -697,11 +824,11 @@ class Site: ...@@ -697,11 +824,11 @@ class Site:
with a user who has userrights permission (a bureaucrat for eg.). with a user who has userrights permission (a bureaucrat for eg.).
Args: Args:
username (str): MediaWiki username username: MediaWiki username
password (str): MediaWiki password password: MediaWiki password
cookies (dict): Custom cookies to include with the log-in request. cookies: Custom cookies to include with the log-in request.
domain (str): Sends domain name for authentication; used by some domain: Sends domain name for authentication; used by some MediaWiki plug-ins
MediaWiki plug-ins like the 'LDAP Authentication' extension. like the 'LDAP Authentication' extension.
Raises: Raises:
LoginError (mwclient.errors.LoginError): Login failed, the reason can be LoginError (mwclient.errors.LoginError): Login failed, the reason can be
...@@ -754,7 +881,7 @@ class Site: ...@@ -754,7 +881,7 @@ class Site:
self.site_init() self.site_init()
def clientlogin(self, cookies=None, **kwargs): def clientlogin(self, cookies: Optional[Cookies] = None, **kwargs: Any) -> Any:
""" """
Login to the wiki using a username and password. The method returns Login to the wiki using a username and password. The method returns
True if it's a success or the returned response if it's a multi-steps True if it's a success or the returned response if it's a multi-steps
...@@ -764,11 +891,11 @@ class Site: ...@@ -764,11 +891,11 @@ class Site:
>>> try: >>> try:
... site.clientlogin(username='myusername', password='secret') ... site.clientlogin(username='myusername', password='secret')
... except mwclient.errors.LoginError as e: ... except mwclient.errors.LoginError as e:
... print('Could not login to MediaWiki: %s' % e) ... print(f'Could not login to MediaWiki: {e}' )
Args: Args:
cookies (dict): Custom cookies to include with the log-in request. cookies: Custom cookies to include with the log-in request.
**kwargs (dict): Custom vars used for clientlogin as: **kwargs: Custom vars used for clientlogin as:
- loginmergerequestfields - loginmergerequestfields
- loginpreservestate - loginpreservestate
- loginreturnurl, - loginreturnurl,
...@@ -779,6 +906,10 @@ class Site: ...@@ -779,6 +906,10 @@ class Site:
`username` and `password` `username` and `password`
See https://www.mediawiki.org/wiki/API:Login#Method_2._clientlogin See https://www.mediawiki.org/wiki/API:Login#Method_2._clientlogin
Returns:
bool | dict: True if login was successful, or the response if it's a
multi-steps login process you started.
Raises: Raises:
LoginError (mwclient.errors.LoginError): Login failed, the reason can be LoginError (mwclient.errors.LoginError): Login failed, the reason can be
obtained from e.code and e.info (where e is the exception object) and obtained from e.code and e.info (where e is the exception object) and
...@@ -798,40 +929,40 @@ class Site: ...@@ -798,40 +929,40 @@ class Site:
if cookies: if cookies:
self.connection.cookies.update(cookies) self.connection.cookies.update(cookies)
if kwargs: if not kwargs:
# Try to login using the scheme for MW 1.27+. If the wiki is read protected, # TODO: Check if we should raise an error here. It's not clear what the
# it is not possible to get the wiki version upfront using the API, so we just # expected behavior is when no kwargs are passed. To update the
# have to try. If the attempt fails, we try the old method. # cookies, the user can update the connection object directly.
if 'logintoken' not in kwargs: return
try:
kwargs['logintoken'] = self.get_token('login')
except (errors.APIError, KeyError):
log.debug('Failed to get login token, MediaWiki is older than 1.27.')
if 'logincontinue' not in kwargs and 'loginreturnurl' not in kwargs: if 'logintoken' not in kwargs:
# should be great if API didn't require this... kwargs['logintoken'] = self.get_token('login')
kwargs['loginreturnurl'] = '%s://%s' % (self.scheme, self.host)
while True: if 'logincontinue' not in kwargs and 'loginreturnurl' not in kwargs:
login = self.post('clientlogin', **kwargs) kwargs['loginreturnurl'] = f'{self.scheme}://{self.host}'
status = login['clientlogin'].get('status')
if status == 'PASS': response = self.post('clientlogin', **kwargs)
return True
elif status in ('UI', 'REDIRECT'): status = response['clientlogin'].get('status')
return login['clientlogin'] if status == 'PASS':
else: self.site_init()
raise errors.LoginError(self, status, return True
login['clientlogin'].get('message')) elif status in ('UI', 'REDIRECT'):
return response['clientlogin']
else:
raise errors.LoginError(self, status, response['clientlogin'].get('message'))
def get_token(self, type, force=False, title=None): def get_token(
self, type: str, force: bool = False, title: Optional[str] = None
) -> str:
"""Request a MediaWiki access token of the given `type`. """Request a MediaWiki access token of the given `type`.
Args: Args:
type (str): The type of token to request. type: The type of token to request.
force (bool): Force the request of a new token, even if a token of that type force: Force the request of a new token, even if a token of that type has
has already been cached. already been cached.
title (str): The page title for which to request a token. Only used for title: The page title for which to request a token. Only used for MediaWiki
MediaWiki versions below 1.24. versions below 1.24.
Returns: Returns:
A MediaWiki token of the requested `type`. A MediaWiki token of the requested `type`.
...@@ -839,7 +970,7 @@ class Site: ...@@ -839,7 +970,7 @@ class Site:
Raises: Raises:
errors.APIError: A token of the given type could not be retrieved. errors.APIError: A token of the given type could not be retrieved.
""" """
if self.version is None or self.version[:2] >= (1, 24): if self.version is None or self.require(1, 24, raise_error=False):
# The 'csrf' (cross-site request forgery) token introduced in 1.24 replaces # The 'csrf' (cross-site request forgery) token introduced in 1.24 replaces
# the majority of older tokens, like edittoken and movetoken. # the majority of older tokens, like edittoken and movetoken.
if type not in self.AVAILABLE_TOKEN_TYPES: if type not in self.AVAILABLE_TOKEN_TYPES:
...@@ -849,8 +980,7 @@ class Site: ...@@ -849,8 +980,7 @@ class Site:
self.tokens[type] = '0' self.tokens[type] = '0'
if self.tokens.get(type, '0') == '0' or force: if self.tokens.get(type, '0') == '0' or force:
if self.version is None or self.require(1, 24, raise_error=False):
if self.version is None or self.version[:2] >= (1, 24):
# We use raw_api() rather than api() because api() is adding "userinfo" # We use raw_api() rather than api() because api() is adding "userinfo"
# to the query and this raises a readapideniederror if the wiki is read # to the query and this raises a readapideniederror if the wiki is read
# protected, and we're trying to fetch a login token. # protected, and we're trying to fetch a login token.
...@@ -861,7 +991,7 @@ class Site: ...@@ -861,7 +991,7 @@ class Site:
# Note that for read protected wikis, we don't know the version when # Note that for read protected wikis, we don't know the version when
# fetching the login token. If it's < 1.27, the request below will # fetching the login token. If it's < 1.27, the request below will
# raise a KeyError that we should catch. # raise a KeyError that we should catch.
self.tokens[type] = info['query']['tokens']['%stoken' % type] self.tokens[type] = info['query']['tokens'][f'{type}token']
else: else:
if title is None: if title is None:
...@@ -871,7 +1001,7 @@ class Site: ...@@ -871,7 +1001,7 @@ class Site:
prop='info', intoken=type) prop='info', intoken=type)
for i in info['query']['pages'].values(): for i in info['query']['pages'].values():
if i['title'] == title: if i['title'] == title:
self.tokens[type] = i['%stoken' % type] self.tokens[type] = i[f'{type}token']
return self.tokens[type] return self.tokens[type]
...@@ -1230,25 +1360,35 @@ class Site: ...@@ -1230,25 +1360,35 @@ class Site:
res = res.get('userrights', {}) res = res.get('userrights', {})
return {'added': res.get('added', []), 'removed': res.get('removed', [])} return {'added': res.get('added', []), 'removed': res.get('removed', [])}
def upload(self, file=None, filename=None, description='', ignore=False, def upload(
file_size=None, url=None, filekey=None, comment=None): self,
file: Union[str, BinaryIO, None] = None,
filename: Optional[str] = None,
description: str = '',
ignore: bool = False,
file_size: Optional[int] = None,
url: Optional[str] = None,
filekey: Optional[str] = None,
comment: Optional[str] = None
) -> Dict[str, Any]:
"""Upload a file to the site. """Upload a file to the site.
Note that one of `file`, `filekey` and `url` must be specified, but not Note that one of `file`, `filekey` and `url` must be specified, but not
more than one. For normal uploads, you specify `file`. more than one. For normal uploads, you specify `file`.
API doc: https://www.mediawiki.org/wiki/API:Upload
Args: Args:
file (str): File object or stream to upload. file: File object or stream to upload.
filename (str): Destination filename, don't include namespace filename: Destination filename, don't include namespace prefix like 'File:'
prefix like 'File:' description: Wikitext for the file description page.
description (str): Wikitext for the file description page. ignore: True to upload despite any warnings.
ignore (bool): True to upload despite any warnings. file_size: Deprecated in mwclient 0.7
file_size (int): Deprecated in mwclient 0.7 url: URL to fetch the file from.
url (str): URL to fetch the file from. filekey: Key that identifies a previous upload that was stashed temporarily.
filekey (str): Key that identifies a previous upload that was comment: Upload comment. Also used as the initial page text for new files if
stashed temporarily. `description` is not specified.
comment (str): Upload comment. Also used as the initial page text
for new files if `description` is not specified.
Example: Example:
...@@ -1261,6 +1401,7 @@ class Site: ...@@ -1261,6 +1401,7 @@ class Site:
Raises: Raises:
errors.InsufficientPermission errors.InsufficientPermission
requests.exceptions.HTTPError requests.exceptions.HTTPError
errors.FileExists: The file already exists and `ignore` is `False`.
""" """
if file_size is not None: if file_size is not None:
...@@ -1293,10 +1434,15 @@ class Site: ...@@ -1293,10 +1434,15 @@ class Site:
if not hasattr(file, 'read'): if not hasattr(file, 'read'):
file = open(file, 'rb') file = open(file, 'rb')
# Narrowing the type of file from Union[str, BinaryIO, None]
# to BinaryIO, since we know it's not a str at this point.
file = cast(BinaryIO, file)
content_size = file.seek(0, 2) content_size = file.seek(0, 2)
file.seek(0) file.seek(0)
if self.version[:2] >= (1, 20) and content_size > self.chunk_size: if (self.require(1, 20, raise_error=False)
and content_size > self.chunk_size):
return self.chunk_upload(file, filename, ignore, comment, text) return self.chunk_upload(file, filename, ignore, comment, text)
predata = { predata = {
...@@ -1315,7 +1461,7 @@ class Site: ...@@ -1315,7 +1461,7 @@ class Site:
# sessionkey was renamed to filekey in MediaWiki 1.18 # sessionkey was renamed to filekey in MediaWiki 1.18
# https://phabricator.wikimedia.org/rMW5f13517e36b45342f228f3de4298bb0fe186995d # https://phabricator.wikimedia.org/rMW5f13517e36b45342f228f3de4298bb0fe186995d
if self.version[:2] < (1, 18): if not self.require(1, 18, raise_error=False):
predata['sessionkey'] = filekey predata['sessionkey'] = filekey
else: else:
predata['filekey'] = filekey predata['filekey'] = filekey
...@@ -1323,7 +1469,6 @@ class Site: ...@@ -1323,7 +1469,6 @@ class Site:
postdata = predata postdata = predata
files = None files = None
if file is not None: if file is not None:
# Workaround for https://github.com/mwclient/mwclient/issues/65 # Workaround for https://github.com/mwclient/mwclient/issues/65
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# Since the filename in Content-Disposition is not interpreted, # Since the filename in Content-Disposition is not interpreted,
...@@ -1338,13 +1483,29 @@ class Site: ...@@ -1338,13 +1483,29 @@ class Site:
if not info: if not info:
info = {} info = {}
if self.handle_api_result(info, kwargs=predata, sleeper=sleeper): if self.handle_api_result(info, kwargs=predata, sleeper=sleeper):
response = info.get('upload', {}) response = info.get('upload', {}) # type: Dict[str, Any]
# Workaround for https://github.com/mwclient/mwclient/issues/211
# ----------------------------------------------------------------
# Raise an error if the file already exists. This is necessary because
# MediaWiki returns a warning, not an error, leading to silent failure.
# The user must explicitly set ignore=True (ignorewarnings=True) to
# overwrite an existing file.
if ignore is False and 'exists' in response.get('warnings', {}):
raise errors.FileExists(filename)
break break
if file is not None: if file is not None:
file.close() file.close()
return response return response
def chunk_upload(self, file, filename, ignorewarnings, comment, text): def chunk_upload(
self,
file: BinaryIO,
filename: str,
ignorewarnings: bool,
comment: str,
text: Optional[str]
) -> Dict[str, Any]:
"""Upload a file to the site in chunks. """Upload a file to the site in chunks.
This method is called by `Site.upload` if you are connecting to a newer This method is called by `Site.upload` if you are connecting to a newer
...@@ -1352,8 +1513,11 @@ class Site: ...@@ -1352,8 +1513,11 @@ class Site:
method directly. method directly.
Args: Args:
file (file-like object): File object or stream to upload. file: File object or stream to upload.
params (dict): Dict containing upload parameters. filename: Destination filename.
ignorewarnings: True to upload despite any warnings.
comment: Upload comment.
text: Initial page text for new files.
""" """
image = self.Images[filename] image = self.Images[filename]
...@@ -1379,7 +1543,7 @@ class Site: ...@@ -1379,7 +1543,7 @@ class Site:
data = self.raw_call('api', params, files={'chunk': chunk}) data = self.raw_call('api', params, files={'chunk': chunk})
info = json.loads(data) info = json.loads(data)
if self.handle_api_result(info, kwargs=params, sleeper=sleeper): if self.handle_api_result(info, kwargs=params, sleeper=sleeper):
response = info.get('upload', {}) response = info.get('upload', {}) # type: Dict[str, Any]
break break
offset += chunk.tell() offset += chunk.tell()
...@@ -1405,20 +1569,29 @@ class Site: ...@@ -1405,20 +1569,29 @@ class Site:
params['text'] = text params['text'] = text
return self.post('upload', **params) return self.post('upload', **params)
def parse(self, text=None, title=None, page=None, prop=None, def parse(
redirects=False, mobileformat=False): self,
text: Optional[str] = None,
title: Optional[str] = None,
page: Optional[str] = None,
prop: Optional[str] = None,
redirects: bool = False,
mobileformat: bool = False
) -> Any:
"""Parses the given content and returns parser output. """Parses the given content and returns parser output.
API doc: https://www.mediawiki.org/wiki/API:Parse
Args: Args:
text (str): Text to parse. text: Text to parse.
title (str): Title of page the text belongs to. title: Title of page the text belongs to.
page (str): The name of a page to parse. Cannot be used together with text page: The name of a page to parse. Cannot be used together with text
and title. and title.
prop (str): Which pieces of information to get. Multiple alues should be prop: Which pieces of information to get. Multiple alues should be
separated using the pipe (`|`) character. separated using the pipe (`|`) character.
redirects (bool): Resolve the redirect, if the given `page` is a redirect. redirects: Resolve the redirect, if the given `page` is a redirect.
Defaults to `False`. Defaults to `False`.
mobileformat (bool): Return parse output in a format suitable for mobile mobileformat: Return parse output in a format suitable for mobile
devices. Defaults to `False`. devices. Defaults to `False`.
Returns: Returns:
...@@ -1444,7 +1617,12 @@ class Site: ...@@ -1444,7 +1617,12 @@ class Site:
# def unblock: TODO? # def unblock: TODO?
# def import: TODO? # def import: TODO?
def patrol(self, rcid=None, revid=None, tags=None): def patrol(
self,
rcid: Optional[int] = None,
revid: Optional[int] = None,
tags: Optional[str] = None
) -> Any:
"""Patrol a page or a revision. Either ``rcid`` or ``revid`` (but not both) must """Patrol a page or a revision. Either ``rcid`` or ``revid`` (but not both) must
be given. be given.
The ``rcid`` and ``revid`` arguments may be obtained using the The ``rcid`` and ``revid`` arguments may be obtained using the
...@@ -1453,9 +1631,9 @@ class Site: ...@@ -1453,9 +1631,9 @@ class Site:
API doc: https://www.mediawiki.org/wiki/API:Patrol API doc: https://www.mediawiki.org/wiki/API:Patrol
Args: Args:
rcid (int): The recentchanges ID to patrol. rcid: The recentchanges ID to patrol.
revid (int): The revision ID to patrol. revid: The revision ID to patrol.
tags (str): Change tags to apply to the entry in the patrol log. Multiple tags: Change tags to apply to the entry in the patrol log. Multiple
tags can be given, by separating them with the pipe (|) character. tags can be given, by separating them with the pipe (|) character.
Returns: Returns:
...@@ -1484,12 +1662,31 @@ class Site: ...@@ -1484,12 +1662,31 @@ class Site:
return result['patrol'] return result['patrol']
# Lists # Lists
def allpages(self, start=None, prefix=None, namespace='0', filterredir='all', def allpages(
minsize=None, maxsize=None, prtype=None, prlevel=None, self,
limit=None, dir='ascending', filterlanglinks='all', generator=True, start: Optional[str] = None,
end=None): prefix: Optional[str] = None,
"""Retrieve all pages on the wiki as a generator.""" namespace: Namespace = '0',
filterredir: str = 'all',
minsize: Optional[int] = None,
maxsize: Optional[int] = None,
prtype: Optional[str] = None,
prlevel: Optional[str] = None,
limit: Optional[int] = None,
dir: str = 'ascending',
filterlanglinks: str = 'all',
generator: bool = True,
end: Optional[str] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""
Retrieve all pages on the wiki as a generator.
API doc: https://www.mediawiki.org/wiki/API:Allpages
"""
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
pfx = listing.List.get_prefix('ap', generator) pfx = listing.List.get_prefix('ap', generator)
kwargs = dict(listing.List.generate_kwargs( kwargs = dict(listing.List.generate_kwargs(
pfx, ('from', start), ('to', end), prefix=prefix, pfx, ('from', start), ('to', end), prefix=prefix,
...@@ -1498,60 +1695,143 @@ class Site: ...@@ -1498,60 +1695,143 @@ class Site:
filterlanglinks=filterlanglinks, filterlanglinks=filterlanglinks,
)) ))
return listing.List.get_list(generator)(self, 'allpages', 'ap', return listing.List.get_list(generator)(self, 'allpages', 'ap',
limit=limit, return_values='title', max_items=max_items,
api_chunk_size=api_chunk_size,
return_values='title',
**kwargs) **kwargs)
def allimages(self, start=None, prefix=None, minsize=None, maxsize=None, limit=None, def allimages(
dir='ascending', sha1=None, sha1base36=None, generator=True, end=None): self,
"""Retrieve all images on the wiki as a generator.""" start: Optional[str] = None,
prefix: Optional[str] = None,
minsize: Optional[int] = None,
maxsize: Optional[int] = None,
limit: Optional[int] = None,
dir: str = 'ascending',
sha1: Optional[str] = None,
sha1base36: Optional[str] = None,
generator: bool = True,
end: Optional[str] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""
Retrieve all images on the wiki as a generator.
API doc: https://www.mediawiki.org/wiki/API:Allimages
"""
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
pfx = listing.List.get_prefix('ai', generator) pfx = listing.List.get_prefix('ai', generator)
kwargs = dict(listing.List.generate_kwargs( kwargs = dict(listing.List.generate_kwargs(
pfx, ('from', start), ('to', end), prefix=prefix, pfx, ('from', start), ('to', end), prefix=prefix,
minsize=minsize, maxsize=maxsize, minsize=minsize, maxsize=maxsize,
dir=dir, sha1=sha1, sha1base36=sha1base36, dir=dir, sha1=sha1, sha1base36=sha1base36
)) ))
return listing.List.get_list(generator)(self, 'allimages', 'ai', limit=limit, return listing.List.get_list(generator)(self, 'allimages', 'ai',
max_items=max_items,
api_chunk_size=api_chunk_size,
return_values='timestamp|url', return_values='timestamp|url',
**kwargs) **kwargs)
def alllinks(self, start=None, prefix=None, unique=False, prop='title', def alllinks(
namespace='0', limit=None, generator=True, end=None): self,
"""Retrieve a list of all links on the wiki as a generator.""" start: Optional[str] = None,
prefix: Optional[str] = None,
unique: bool = False,
prop: str = 'title',
namespace: Namespace = '0',
limit: Optional[int] = None,
generator: bool = True,
end: Optional[str] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""
Retrieve a list of all links on the wiki as a generator.
API doc: https://www.mediawiki.org/wiki/API:Alllinks
"""
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
pfx = listing.List.get_prefix('al', generator) pfx = listing.List.get_prefix('al', generator)
kwargs = dict(listing.List.generate_kwargs(pfx, ('from', start), ('to', end), kwargs = dict(listing.List.generate_kwargs(pfx, ('from', start), ('to', end),
prefix=prefix, prefix=prefix,
prop=prop, namespace=namespace)) prop=prop, namespace=namespace))
if unique: if unique:
kwargs[pfx + 'unique'] = '1' kwargs[pfx + 'unique'] = '1'
return listing.List.get_list(generator)(self, 'alllinks', 'al', limit=limit, return listing.List.get_list(generator)(self, 'alllinks', 'al',
max_items=max_items,
api_chunk_size=api_chunk_size,
return_values='title', **kwargs) return_values='title', **kwargs)
def allcategories(self, start=None, prefix=None, dir='ascending', limit=None, def allcategories(
generator=True, end=None): self,
"""Retrieve all categories on the wiki as a generator.""" start: Optional[str] = None,
prefix: Optional[str] = None,
dir: str = 'ascending',
limit: Optional[int] = None,
generator: bool = True,
end: Optional[str] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""
Retrieve all categories on the wiki as a generator.
API doc: https://www.mediawiki.org/wiki/API:Allcategories
"""
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
pfx = listing.List.get_prefix('ac', generator) pfx = listing.List.get_prefix('ac', generator)
kwargs = dict(listing.List.generate_kwargs(pfx, ('from', start), ('to', end), kwargs = dict(listing.List.generate_kwargs(pfx, ('from', start), ('to', end),
prefix=prefix, dir=dir)) prefix=prefix, dir=dir))
return listing.List.get_list(generator)(self, 'allcategories', 'ac', limit=limit, return listing.List.get_list(generator)(self, 'allcategories', 'ac',
**kwargs) max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def allusers(
self,
start: Optional[str] = None,
prefix: Optional[str] = None,
group: Optional[str] = None,
prop: Optional[str] = None,
limit: Optional[int] = None,
witheditsonly: bool = False,
activeusers: bool = False,
rights: Optional[str] = None,
end: Optional[str] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""
Retrieve all users on the wiki as a generator.
def allusers(self, start=None, prefix=None, group=None, prop=None, limit=None, API doc: https://www.mediawiki.org/wiki/API:Allusers
witheditsonly=False, activeusers=False, rights=None, end=None): """
"""Retrieve all users on the wiki as a generator."""
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('au', ('from', start), ('to', end), kwargs = dict(listing.List.generate_kwargs('au', ('from', start), ('to', end),
prefix=prefix, prefix=prefix,
group=group, prop=prop, group=group, prop=prop,
rights=rights, rights=rights,
witheditsonly=witheditsonly, witheditsonly=witheditsonly,
activeusers=activeusers)) activeusers=activeusers))
return listing.List(self, 'allusers', 'au', limit=limit, **kwargs) return listing.List(self, 'allusers', 'au', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def blocks(self, start=None, end=None, dir='older', ids=None, users=None, limit=None,
prop='id|user|by|timestamp|expiry|reason|flags'): def blocks(
self,
start: Optional[str] = None,
end: Optional[str] = None,
dir: str = 'older',
ids: Optional[str] = None,
users: Optional[str] = None,
limit: Optional[int] = None,
prop: str = 'id|user|by|timestamp|expiry|reason|flags',
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""Retrieve blocks as a generator. """Retrieve blocks as a generator.
API doc: https://www.mediawiki.org/wiki/API:Blocks API doc: https://www.mediawiki.org/wiki/API:Blocks
...@@ -1577,22 +1857,50 @@ class Site: ...@@ -1577,22 +1857,50 @@ class Site:
""" """
# TODO: Fix. Fix what? # TODO: Fix. Fix what?
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('bk', start=start, end=end, dir=dir, kwargs = dict(listing.List.generate_kwargs('bk', start=start, end=end, dir=dir,
ids=ids, users=users, prop=prop)) ids=ids, users=users, prop=prop))
return listing.List(self, 'blocks', 'bk', limit=limit, **kwargs) return listing.List(self, 'blocks', 'bk', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def deletedrevisions(
self,
start: Optional[str] = None,
end: Optional[str] = None,
dir: str = 'older',
namespace: Optional[int] = None,
limit: Optional[int] = None,
prop: str = 'user|comment',
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""
Retrieve deleted revisions as a generator.
def deletedrevisions(self, start=None, end=None, dir='older', namespace=None, API doc: https://www.mediawiki.org/wiki/API:Deletedrevs
limit=None, prop='user|comment'): """
# TODO: Fix # TODO: Fix
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('dr', start=start, end=end, dir=dir, kwargs = dict(listing.List.generate_kwargs('dr', start=start, end=end, dir=dir,
namespace=namespace, prop=prop)) namespace=namespace, prop=prop))
return listing.List(self, 'deletedrevs', 'dr', limit=limit, **kwargs) return listing.List(self, 'deletedrevs', 'dr', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def exturlusage(self, query, prop=None, protocol='http', namespace=None, limit=None):
def exturlusage(
self,
query: str,
prop: Optional[str] = None,
protocol: str = 'http',
namespace: Optional[Namespace] = None,
limit: Optional[int] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
r"""Retrieve the list of pages that link to a particular domain or URL, r"""Retrieve the list of pages that link to a particular domain or URL,
as a generator. as a generator.
API doc: https://www.mediawiki.org/wiki/API:Exturlusage
This API call mirrors the Special:LinkSearch function on-wiki. This API call mirrors the Special:LinkSearch function on-wiki.
Query can be a domain like 'bbc.co.uk'. Query can be a domain like 'bbc.co.uk'.
...@@ -1611,53 +1919,121 @@ class Site: ...@@ -1611,53 +1919,121 @@ class Site:
""" """
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('eu', query=query, prop=prop, kwargs = dict(listing.List.generate_kwargs('eu', query=query, prop=prop,
protocol=protocol, protocol=protocol,
namespace=namespace)) namespace=namespace))
return listing.List(self, 'exturlusage', 'eu', limit=limit, **kwargs) return listing.List(self, 'exturlusage', 'eu', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def logevents(
self,
type: Optional[str] = None,
prop: Optional[str] = None,
start: Optional[str] = None,
end: Optional[str] = None,
dir: str = 'older',
user: Optional[str] = None,
title: Optional[str] = None,
limit: Optional[int] = None,
action: Optional[str] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""
Retrieve logevents as a generator.
def logevents(self, type=None, prop=None, start=None, end=None, API doc: https://www.mediawiki.org/wiki/API:Logevents
dir='older', user=None, title=None, limit=None, action=None): """
"""Retrieve logevents as a generator."""
kwargs = dict(listing.List.generate_kwargs('le', prop=prop, type=type, kwargs = dict(listing.List.generate_kwargs('le', prop=prop, type=type,
start=start, end=end, dir=dir, start=start, end=end, dir=dir,
user=user, title=title, action=action)) user=user, title=title, action=action))
return listing.List(self, 'logevents', 'le', limit=limit, **kwargs) return listing.List(self, 'logevents', 'le', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def checkuserlog(self, user=None, target=None, limit=10, dir='older',
start=None, end=None): def checkuserlog(
self,
user: Optional[str] = None,
target: Optional[str] = None,
limit: Optional[int] = None,
dir: str = 'older',
start: Optional[str] = None,
end: Optional[str] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = 10
) -> listing.NestedList:
"""Retrieve checkuserlog items as a generator.""" """Retrieve checkuserlog items as a generator."""
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('cul', target=target, start=start, kwargs = dict(listing.List.generate_kwargs('cul', target=target, start=start,
end=end, dir=dir, user=user)) end=end, dir=dir, user=user))
return listing.NestedList('entries', self, 'checkuserlog', 'cul', return listing.NestedList(
limit=limit, **kwargs) 'entries',
self,
'checkuserlog',
'cul',
max_items=max_items,
api_chunk_size=api_chunk_size,
**kwargs,
)
# def protectedtitles requires 1.15 # def protectedtitles requires 1.15
def random(self, namespace, limit=20): def random(
self,
namespace: Namespace,
limit: Optional[int] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = 20
) -> listing.List:
"""Retrieve a generator of random pages from a particular namespace. """Retrieve a generator of random pages from a particular namespace.
limit specifies the number of random articles retrieved. API doc: https://www.mediawiki.org/wiki/API:Random
max_items specifies the number of random articles retrieved.
api_chunk_size and limit (deprecated) specify the API chunk size.
namespace is a namespace identifier integer. namespace is a namespace identifier integer.
Generator contains dictionary with namespace, page ID and title. Generator contains dictionary with namespace, page ID and title.
""" """
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('rn', namespace=namespace)) kwargs = dict(listing.List.generate_kwargs('rn', namespace=namespace))
return listing.List(self, 'random', 'rn', limit=limit, **kwargs) return listing.List(self, 'random', 'rn', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def recentchanges(
self,
start: Optional[str] = None,
end: Optional[str] = None,
dir: str = 'older',
namespace: Optional[Namespace] = None,
prop: Optional[str] = None,
show: Optional[str] = None,
limit: Optional[int] = None,
type: Optional[str] = None,
toponly: Optional[bool] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""
List recent changes to the wiki, à la Special:Recentchanges.
def recentchanges(self, start=None, end=None, dir='older', namespace=None, API doc: https://www.mediawiki.org/wiki/API:Recentchanges
prop=None, show=None, limit=None, type=None, toponly=None):
"""List recent changes to the wiki, à la Special:Recentchanges.
""" """
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('rc', start=start, end=end, dir=dir, kwargs = dict(listing.List.generate_kwargs('rc', start=start, end=end, dir=dir,
namespace=namespace, prop=prop, namespace=namespace, prop=prop,
show=show, type=type, show=show, type=type,
toponly='1' if toponly else None)) toponly='1' if toponly else None))
return listing.List(self, 'recentchanges', 'rc', limit=limit, **kwargs) return listing.List(self, 'recentchanges', 'rc', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def revisions(self, revids, prop='ids|timestamp|flags|comment|user'):
def revisions(
self,
revids: List[Union[int, str]],
prop: str = 'ids|timestamp|flags|comment|user',
) -> List[Dict[str, Any]]:
"""Get data about a list of revisions. """Get data about a list of revisions.
See also the `Page.revisions()` method. See also the `Page.revisions()` method.
...@@ -1670,8 +2046,8 @@ class Site: ...@@ -1670,8 +2046,8 @@ class Site:
... print(revision['*']) ... print(revision['*'])
Args: Args:
revids (list): A list of (max 50) revisions. revids: A list of (max 50) revisions.
prop (str): Which properties to get for each revision. prop: Which properties to get for each revision.
Returns: Returns:
A list of revisions A list of revisions
...@@ -1692,7 +2068,16 @@ class Site: ...@@ -1692,7 +2068,16 @@ class Site:
revisions.append(revision) revisions.append(revision)
return revisions return revisions
def search(self, search, namespace='0', what=None, redirects=False, limit=None): def search(
self,
search: str,
namespace: Namespace = '0',
what: Optional[str] = None,
redirects: bool = False,
limit: Optional[int] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
"""Perform a full text search. """Perform a full text search.
API doc: https://www.mediawiki.org/wiki/API:Search API doc: https://www.mediawiki.org/wiki/API:Search
...@@ -1702,41 +2087,60 @@ class Site: ...@@ -1702,41 +2087,60 @@ class Site:
... print(result.get('title')) ... print(result.get('title'))
Args: Args:
search (str): The query string search: The query string
namespace (int): The namespace to search (default: 0) namespace: The namespace to search (default: 0)
what (str): Search scope: 'text' for fulltext, or 'title' for titles only. what: Search scope: 'text' for fulltext, or 'title' for titles only.
Depending on the search backend, Depending on the search backend,
both options may not be available. both options may not be available.
For instance For instance
`CirrusSearch <https://www.mediawiki.org/wiki/Help:CirrusSearch>`_ `CirrusSearch <https://www.mediawiki.org/wiki/Help:CirrusSearch>`_
doesn't support 'title', but instead provides an "intitle:" doesn't support 'title', but instead provides an "intitle:"
query string filter. query string filter.
redirects (bool): Include redirect pages in the search redirects: Include redirect pages in the search
(option removed in MediaWiki 1.23). (option removed in MediaWiki 1.23).
Returns: Returns:
mwclient.listings.List: Search results iterator mwclient.listings.List: Search results iterator
""" """
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('sr', search=search, kwargs = dict(listing.List.generate_kwargs('sr', search=search,
namespace=namespace, what=what)) namespace=namespace, what=what))
if redirects: if redirects:
kwargs['srredirects'] = '1' kwargs['srredirects'] = '1'
return listing.List(self, 'search', 'sr', limit=limit, **kwargs) return listing.List(self, 'search', 'sr', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def usercontributions(self, user, start=None, end=None, dir='older', namespace=None,
prop=None, show=None, limit=None, uselang=None): def usercontributions(
self,
user: str,
start: Optional[str] = None,
end: Optional[str] = None,
dir: str = 'older',
namespace: Optional[Namespace] = None,
prop: Optional[str] = None,
show: Optional[str] = None,
limit: Optional[int] = None,
uselang: Optional[str] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
""" """
List the contributions made by a given user to the wiki. List the contributions made by a given user to the wiki.
API doc: https://www.mediawiki.org/wiki/API:Usercontribs API doc: https://www.mediawiki.org/wiki/API:Usercontribs
""" """
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('uc', user=user, start=start, end=end, kwargs = dict(listing.List.generate_kwargs('uc', user=user, start=start, end=end,
dir=dir, namespace=namespace, dir=dir, namespace=namespace,
prop=prop, show=show)) prop=prop, show=show))
return listing.List(self, 'usercontribs', 'uc', limit=limit, uselang=uselang, return listing.List(self, 'usercontribs', 'uc', max_items=max_items,
**kwargs) api_chunk_size=api_chunk_size, uselang=uselang, **kwargs)
def users(self, users, prop='blockinfo|groups|editcount'): def users(
self,
users: Iterable[str],
prop: str = 'blockinfo|groups|editcount'
) -> listing.List:
""" """
Get information about a list of users. Get information about a list of users.
...@@ -1745,55 +2149,99 @@ class Site: ...@@ -1745,55 +2149,99 @@ class Site:
return listing.List(self, 'users', 'us', ususers='|'.join(users), usprop=prop) return listing.List(self, 'users', 'us', ususers='|'.join(users), usprop=prop)
def watchlist(self, allrev=False, start=None, end=None, namespace=None, dir='older', def watchlist(
prop=None, show=None, limit=None): self,
allrev: bool = False,
start: Optional[str] = None,
end: Optional[str] = None,
namespace: Optional[Namespace] = None,
dir: str = 'older',
prop: Optional[str] = None,
show: Optional[str] = None,
limit: Optional[int] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> listing.List:
""" """
List the pages on the current user's watchlist. List the pages on the current user's watchlist.
API doc: https://www.mediawiki.org/wiki/API:Watchlist API doc: https://www.mediawiki.org/wiki/API:Watchlist
""" """
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('wl', start=start, end=end, kwargs = dict(listing.List.generate_kwargs('wl', start=start, end=end,
namespace=namespace, dir=dir, namespace=namespace, dir=dir,
prop=prop, show=show)) prop=prop, show=show))
if allrev: if allrev:
kwargs['wlallrev'] = '1' kwargs['wlallrev'] = '1'
return listing.List(self, 'watchlist', 'wl', limit=limit, **kwargs) return listing.List(self, 'watchlist', 'wl', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
def expandtemplates(self, text, title=None, generatexml=False): def expandtemplates(
self, text: str, title: Optional[str] = None, generatexml: bool = False
) -> Union[str, Tuple[str, str]]:
""" """
Takes wikitext (text) and expands templates. Takes wikitext (text) and expands templates.
API doc: https://www.mediawiki.org/wiki/API:Expandtemplates API doc: https://www.mediawiki.org/wiki/API:Expandtemplates
Args: Args:
text (str): Wikitext to convert. text: Wikitext to convert.
title (str): Title of the page. title: Title of the page.
generatexml (bool): Generate the XML parse tree. Defaults to `False`. generatexml: Generate the XML parse tree. Defaults to `False`.
Returns:
If generatexml is False, returns the expanded wikitext as a string.
If generatexml is True, returns a tuple of (expanded wikitext,
XML parse tree).
""" """
if self.require(1, 24, raise_error=False):
return self._expandtemplates_1_24(text, title, generatexml)
else:
return self._expandtemplates_1_13(text, title, generatexml)
def _expandtemplates_1_24(
self, text: str, title: Optional[str], generatexml: bool
) -> Union[str, Tuple[str, str]]:
"""Expand templates using the 1.24 API."""
result = self.post(
'expandtemplates',
text=text,
title=title,
prop='wikitext|parsetree' if generatexml else 'wikitext',
)
kwargs = {} wikitext = str(result['expandtemplates']['wikitext'])
if title is not None:
kwargs['title'] = title
if generatexml: if generatexml:
# FIXME: Deprecated and replaced by `prop=parsetree`. parsetree = str(result['expandtemplates']['parsetree'])
kwargs['generatexml'] = '1' return wikitext, parsetree
return wikitext
result = self.post('expandtemplates', text=text, **kwargs)
def _expandtemplates_1_13(
self, text: str, title: Optional[str], generatexml: bool
) -> Union[str, Tuple[str, str]]:
"""Expand templates using the 1.13-1.23 API."""
result = self.post(
'expandtemplates',
text=text,
title=title,
generatexml='1' if generatexml else None,
)
wikitext = str(result['expandtemplates']['*'])
if generatexml: if generatexml:
return result['expandtemplates']['*'], result['parsetree']['*'] parsetree = str(result['parsetree']['*'])
else: return wikitext, parsetree
return result['expandtemplates']['*'] return wikitext
def ask(self, query, title=None): def ask(self, query: str, title: Optional[str] = None) -> Iterable[Dict[str, Any]]:
""" """
Ask a query against Semantic MediaWiki. Ask a query against Semantic MediaWiki.
API doc: https://semantic-mediawiki.org/wiki/Ask_API API doc: https://semantic-mediawiki.org/wiki/Ask_API
Args: Args:
query (str): The SMW query to be executed. query: The SMW query to be executed.
Returns: Returns:
Generator for retrieving all search results, with each answer as a dictionary. Generator for retrieving all search results, with each answer as a dictionary.
...@@ -1808,16 +2256,16 @@ class Site: ...@@ -1808,16 +2256,16 @@ class Site:
>>> print(title) >>> print(title)
>>> print(data) >>> print(data)
""" """
kwargs = {} kwargs = {} # type: Dict[str, Any]
if title is None: if title is not None:
kwargs['title'] = title kwargs['title'] = title
offset = 0 offset = 0 # type: Optional[int]
while offset is not None: while offset is not None:
results = self.raw_api('ask', query='{query}|offset={offset}'.format( results = self.raw_api('ask', query=f'{query}|offset={offset}',
query=query, offset=offset), http_method='GET', **kwargs) http_method='GET', **kwargs)
self.handle_api_result(results) # raises APIError on error self.handle_api_result(results) # raises APIError on error
offset = results.get('query-continue-offset') offset = cast(Optional[int], results.get('query-continue-offset'))
answers = results['query'].get('results', []) answers = results['query'].get('results', [])
if isinstance(answers, dict): if isinstance(answers, dict):
...@@ -1826,5 +2274,4 @@ class Site: ...@@ -1826,5 +2274,4 @@ class Site:
# with the page title as key. # with the page title as key.
answers = [answer for answer in answers.values()] answers = [answer for answer in answers.values()]
for answer in answers: yield from answers
yield answer
from typing import Any, TYPE_CHECKING, Optional, cast
if TYPE_CHECKING:
import mwclient.page
class MwClientError(RuntimeError): class MwClientError(RuntimeError):
"""Base class for all mwclient errors."""
pass pass
class MediaWikiVersionError(MwClientError): class MediaWikiVersionError(MwClientError):
"""The version of MediaWiki is not supported."""
pass pass
class APIDisabledError(MwClientError): class APIDisabledError(MwClientError):
"""The API is disabled on the wiki."""
pass pass
class MaximumRetriesExceeded(MwClientError): class MaximumRetriesExceeded(MwClientError):
"""The maximum number of retries for a request has been exceeded."""
pass pass
class APIError(MwClientError): class APIError(MwClientError):
"""Base class for errors returned by the MediaWiki API.
def __init__(self, code, info, kwargs): Attributes:
code (Optional[str]): The error code returned by the API.
info (str): The error message returned by the API.
kwargs (Optional[Any]): Additional information.
"""
def __init__(self, code: Optional[str], info: str, kwargs: Optional[Any]) -> None:
self.code = code self.code = code
self.info = info self.info = info
super(APIError, self).__init__(code, info, kwargs) super().__init__(code, info, kwargs)
class UserNotFound(APIError): class UserNotFound(APIError):
...@@ -31,38 +48,80 @@ class UserCreateError(APIError): ...@@ -31,38 +48,80 @@ class UserCreateError(APIError):
class InsufficientPermission(MwClientError): class InsufficientPermission(MwClientError):
"""Raised when the user does not have sufficient permissions to perform an
action."""
pass pass
class UserBlocked(InsufficientPermission): class UserBlocked(InsufficientPermission):
"""Raised when attempting to perform an action while blocked."""
pass pass
class EditError(MwClientError): class EditError(MwClientError):
"""Base class for errors related to editing pages."""
pass pass
class ProtectedPageError(EditError, InsufficientPermission): class ProtectedPageError(EditError, InsufficientPermission):
"""Raised when attempting to edit a protected page.
def __init__(self, page, code=None, info=None):
Attributes:
page (mwclient.page.Page): The page for which the edit attempt was made.
code (Optional[str]): The error code returned by the API.
info (Optional[str]): The error message returned by the API.
"""
def __init__(
self,
page: 'mwclient.page.Page',
code: Optional[str] = None,
info: Optional[str] = None
) -> None:
self.page = page self.page = page
self.code = code self.code = code
self.info = info self.info = info
def __str__(self): def __str__(self) -> str:
if self.info is not None: if self.info is not None:
return self.info return self.info
return 'You do not have the "edit" right.' return 'You do not have the "edit" right.'
class FileExists(EditError): class FileExists(EditError):
pass """
Raised when trying to upload a file that already exists.
See also: https://www.mediawiki.org/wiki/API:Upload#Upload_warnings
Attributes:
file_name (str): The name of the file that already exists.
"""
def __init__(self, file_name: str) -> None:
self.file_name = file_name
def __str__(self) -> str:
return (
f'The file "{self.file_name}" already exists. '
f'Set ignore=True to overwrite it.'
)
class LoginError(MwClientError):
def __init__(self, site, code, info): class LoginError(MwClientError):
super(LoginError, self).__init__( """Base class for login errors.
Attributes:
site (mwclient.site.Site): The site object on which the login attempt
was made.
code (str): The error code returned by the API.
info (str): The error message returned by the API.
"""
def __init__(
self, site: 'mwclient.client.Site', code: Optional[str], info: str
) -> None:
super().__init__(
site, site,
{'result': code, 'reason': info} # For backwards-compability {'result': code, 'reason': info} # For backwards-compability
) )
...@@ -70,43 +129,54 @@ class LoginError(MwClientError): ...@@ -70,43 +129,54 @@ class LoginError(MwClientError):
self.code = code self.code = code
self.info = info self.info = info
def __str__(self): def __str__(self) -> str:
return self.info return self.info
class OAuthAuthorizationError(LoginError): class OAuthAuthorizationError(LoginError):
"""Raised when OAuth authorization fails.
Attributes:
site (mwclient.site.Site): The site object on which the login attempt
was made.
code (str): The error code returned by the API.
info (str): The error message returned by the API.
"""
pass pass
class AssertUserFailedError(MwClientError): class AssertUserFailedError(MwClientError):
"""Raised when the user assertion fails."""
def __init__(self): def __init__(self) -> None:
super(AssertUserFailedError, self).__init__(( super().__init__(
'By default, mwclient protects you from accidentally editing ' 'By default, mwclient protects you from accidentally editing '
'without being logged in. If you actually want to edit without ' 'without being logged in. If you actually want to edit without '
'logging in, you can set force_login on the Site object to False.' 'logging in, you can set force_login on the Site object to False.'
)) )
def __str__(self): def __str__(self) -> str:
return self.args[0] return cast(str, self.args[0])
class EmailError(MwClientError): class EmailError(MwClientError):
"""Base class for email errors."""
pass pass
class NoSpecifiedEmail(EmailError): class NoSpecifiedEmail(EmailError):
pass """Raised when trying to email a user who has not specified an email"""
class NoWriteApi(MwClientError):
pass pass
class InvalidResponse(MwClientError): class InvalidResponse(MwClientError):
"""Raised when the server returns an invalid JSON response.
Attributes:
response_text (str): The response text from the server.
"""
def __init__(self, response_text=None): def __init__(self, response_text: Optional[str] = None) -> None:
super(InvalidResponse, self).__init__(( super().__init__((
'Did not get a valid JSON response from the server. Check that ' 'Did not get a valid JSON response from the server. Check that '
'you used the correct hostname. If you did, the server might ' 'you used the correct hostname. If you did, the server might '
'be wrongly configured or experiencing temporary problems.'), 'be wrongly configured or experiencing temporary problems.'),
...@@ -114,9 +184,10 @@ class InvalidResponse(MwClientError): ...@@ -114,9 +184,10 @@ class InvalidResponse(MwClientError):
) )
self.response_text = response_text self.response_text = response_text
def __str__(self): def __str__(self) -> str:
return self.args[0] return cast(str, self.args[0])
class InvalidPageTitle(MwClientError): class InvalidPageTitle(MwClientError):
"""Raised when an invalid page title is used."""
pass pass
import io
from typing import Optional, Mapping, Any, overload
import mwclient.listing import mwclient.listing
import mwclient.page import mwclient.page
from mwclient._types import Namespace
from mwclient.util import handle_limit
class Image(mwclient.page.Page): class Image(mwclient.page.Page):
"""
Represents an image on a MediaWiki wiki represented by a
:class:`~mwclient.client.Site` object.
Args:
site (mwclient.client.Site): The site object this page belongs to.
name (Union[str, int, Page]): The title of the page, the page ID, or
another :class:`Page` object to copy.
info (Optional[dict]): Page info, if already fetched, e.g., when
iterating over a list of pages. If not provided, the page info
will be fetched from the API.
"""
def __init__(self, site, name, info=None): def __init__(
super(Image, self).__init__( self,
site: 'mwclient.client.Site',
name: str,
info: Optional[Mapping[str, Any]] = None
) -> None:
super().__init__(
site, name, info, extra_properties={ site, name, info, extra_properties={
'imageinfo': ( 'imageinfo': (
('iiprop', ('iiprop',
...@@ -16,7 +38,7 @@ class Image(mwclient.page.Page): ...@@ -16,7 +38,7 @@ class Image(mwclient.page.Page):
self.imagerepository = self._info.get('imagerepository', '') self.imagerepository = self._info.get('imagerepository', '')
self.imageinfo = self._info.get('imageinfo', ({}, ))[0] self.imageinfo = self._info.get('imageinfo', ({}, ))[0]
def imagehistory(self): def imagehistory(self) -> 'mwclient.listing.PageProperty':
""" """
Get file revision info for the given file. Get file revision info for the given file.
...@@ -27,8 +49,16 @@ class Image(mwclient.page.Page): ...@@ -27,8 +49,16 @@ class Image(mwclient.page.Page):
iiprop='timestamp|user|comment|url|size|sha1|metadata|mime|archivename' iiprop='timestamp|user|comment|url|size|sha1|metadata|mime|archivename'
) )
def imageusage(self, namespace=None, filterredir='all', redirect=False, def imageusage(
limit=None, generator=True): self,
namespace: Optional[Namespace] = None,
filterredir: str = 'all',
redirect: bool = False,
limit: Optional[int] = None,
generator: bool = True,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> 'mwclient.listing.List':
""" """
List pages that use the given file. List pages that use the given file.
...@@ -38,21 +68,53 @@ class Image(mwclient.page.Page): ...@@ -38,21 +68,53 @@ class Image(mwclient.page.Page):
kwargs = dict(mwclient.listing.List.generate_kwargs( kwargs = dict(mwclient.listing.List.generate_kwargs(
prefix, title=self.name, namespace=namespace, filterredir=filterredir prefix, title=self.name, namespace=namespace, filterredir=filterredir
)) ))
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
if redirect: if redirect:
kwargs['%sredirect' % prefix] = '1' kwargs[f'{prefix}redirect'] = '1'
return mwclient.listing.List.get_list(generator)( return mwclient.listing.List.get_list(generator)(
self.site, 'imageusage', 'iu', limit=limit, return_values='title', **kwargs self.site,
'imageusage',
'iu',
max_items=max_items,
api_chunk_size=api_chunk_size,
return_values='title',
**kwargs
) )
def duplicatefiles(self, limit=None): def duplicatefiles(
self,
limit: Optional[int] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> 'mwclient.listing.PageProperty':
""" """
List duplicates of the current file. List duplicates of the current file.
API doc: https://www.mediawiki.org/wiki/API:Duplicatefiles API doc: https://www.mediawiki.org/wiki/API:Duplicatefiles
limit sets a hard cap on the total number of results, it does
not only specify the API chunk size.
""" """
return mwclient.listing.PageProperty(self, 'duplicatefiles', 'df', dflimit=limit) (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
return mwclient.listing.PageProperty(
self,
'duplicatefiles',
'df',
max_items=max_items,
api_chunk_size=api_chunk_size
)
@overload
def download(self) -> bytes:
...
def download(self, destination=None): @overload
def download(self, destination: io.BufferedWriter) -> None:
...
def download(
self, destination: Optional[io.BufferedWriter] = None
) -> Optional[bytes]:
""" """
Download the file. If `destination` is given, the file will be written Download the file. If `destination` is given, the file will be written
directly to the stream. Otherwise the file content will be stored in memory directly to the stream. Otherwise the file content will be stored in memory
...@@ -64,19 +126,16 @@ class Image(mwclient.page.Page): ...@@ -64,19 +126,16 @@ class Image(mwclient.page.Page):
... image.download(fd) ... image.download(fd)
Args: Args:
destination (file object): Destination file destination: Destination file
""" """
url = self.imageinfo['url'] url = self.imageinfo['url']
if destination is not None: if destination is not None:
res = self.site.connection.get(url, stream=True) res = self.site.connection.get(url, stream=True)
for chunk in res.iter_content(1024): for chunk in res.iter_content(1024):
destination.write(chunk) destination.write(chunk)
return None
else: else:
return self.site.connection.get(url).content return self.site.connection.get(url).content
def __repr__(self): def __repr__(self) -> str:
return "<%s object '%s' for %s>" % ( return f"<{self.__class__.__name__} object '{self.name}' for {self.site}>"
self.__class__.__name__,
self.name.encode('utf-8'),
self.site
)
from mwclient.util import parse_timestamp from typing import ( # noqa: F401
import mwclient.page Optional, Tuple, Any, Union, Iterator, Mapping, Iterable, Type, Dict
)
import mwclient.image import mwclient.image
import mwclient.page
from mwclient._types import Namespace
from mwclient.util import parse_timestamp, handle_limit
class List: class List:
...@@ -9,11 +14,27 @@ class List: ...@@ -9,11 +14,27 @@ class List:
This is a class providing lazy iteration. This means that the This is a class providing lazy iteration. This means that the
content is loaded in chunks as long as the response hints at content is loaded in chunks as long as the response hints at
continuing content. continuing content.
max_items limits the total number of items that will be yielded
by this iterator. api_chunk_size sets the number of items that
will be requested from the wiki per API call (this iterator itself
always yields one item at a time). limit does the same as
api_chunk_size for backward compatibility, but is deprecated due
to its misleading name.
""" """
def __init__(self, site, list_name, prefix, def __init__(
limit=None, return_values=None, max_items=None, self,
*args, **kwargs): site: 'mwclient.client.Site',
list_name: str,
prefix: str,
limit: Optional[int] = None,
return_values: Union[str, Tuple[str, ...], None] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None,
*args: Tuple[str, Any],
**kwargs: Any
) -> None:
# NOTE: Fix limit # NOTE: Fix limit
self.site = site self.site = site
self.list_name = list_name self.list_name = list_name
...@@ -23,23 +44,28 @@ class List: ...@@ -23,23 +44,28 @@ class List:
kwargs.update(args) kwargs.update(args)
self.args = kwargs self.args = kwargs
if limit is None: (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
limit = site.api_limit
self.args[self.prefix + 'limit'] = str(limit) # for efficiency, if max_items is set and api_chunk_size is not,
# set the chunk size to max_items so we don't retrieve
# unneeded extra items (so long as it's below API limit)
api_limit = site.api_limit
api_chunk_size = api_chunk_size or min(max_items or api_limit, api_limit)
self.args[self.prefix + 'limit'] = str(api_chunk_size)
self.count = 0 self.count = 0
self.max_items = max_items self.max_items = max_items
self._iter = iter(range(0)) self._iter = iter(range(0)) # type: Iterator[Any]
self.last = False self.last = False
self.result_member = list_name self.result_member = list_name
self.return_values = return_values self.return_values = return_values
def __iter__(self): def __iter__(self) -> 'List':
return self return self
def __next__(self): def __next__(self) -> Any:
if self.max_items is not None: if self.max_items is not None:
if self.count >= self.max_items: if self.count >= self.max_items:
raise StopIteration raise StopIteration
...@@ -64,12 +90,12 @@ class List: ...@@ -64,12 +90,12 @@ class List:
if isinstance(self, GeneratorList): if isinstance(self, GeneratorList):
return item return item
if type(self.return_values) is tuple: if type(self.return_values) is tuple:
return tuple((item[i] for i in self.return_values)) return tuple(item[i] for i in self.return_values)
if self.return_values is not None: if self.return_values is not None:
return item[self.return_values] return item[self.return_values]
return item return item
def load_chunk(self): def load_chunk(self) -> None:
"""Query a new chunk of data """Query a new chunk of data
If the query is empty, `raise StopIteration`. If the query is empty, `raise StopIteration`.
...@@ -77,11 +103,8 @@ class List: ...@@ -77,11 +103,8 @@ class List:
Else, update the iterator accordingly. Else, update the iterator accordingly.
If 'continue' is in the response, it is added to `self.args` If 'continue' is in the response, it is added to `self.args`
(new style continuation, added in MediaWiki 1.21). (new style continuation, added in MediaWiki 1.21, default
since MediaWiki 1.26).
If not, but 'query-continue' is in the response, query its
item called `self.list_name` and add this to `self.args` (old
style continuation).
Else, set `self.last` to True. Else, set `self.last` to True.
""" """
...@@ -102,14 +125,10 @@ class List: ...@@ -102,14 +125,10 @@ class List:
# New style continuation, added in MediaWiki 1.21 # New style continuation, added in MediaWiki 1.21
self.args.update(data['continue']) self.args.update(data['continue'])
elif self.list_name in data.get('query-continue', ()):
# Old style continuation
self.args.update(data['query-continue'][self.list_name])
else: else:
self.last = True self.last = True
def set_iter(self, data): def set_iter(self, data: Mapping[str, Any]) -> None:
"""Set `self._iter` to the API response `data`.""" """Set `self._iter` to the API response `data`."""
if self.result_member not in data['query']: if self.result_member not in data['query']:
self._iter = iter(range(0)) self._iter = iter(range(0))
...@@ -118,35 +137,33 @@ class List: ...@@ -118,35 +137,33 @@ class List:
else: else:
self._iter = iter(data['query'][self.result_member].values()) self._iter = iter(data['query'][self.result_member].values())
def __repr__(self): def __repr__(self) -> str:
return "<%s object '%s' for %s>" % ( return f"<{self.__class__.__name__} object '{self.list_name}' for {self.site}>"
self.__class__.__name__,
self.list_name,
self.site
)
@staticmethod @staticmethod
def generate_kwargs(_prefix, *args, **kwargs): def generate_kwargs(
_prefix: str, *args: Tuple[str, Any], **kwargs: Any
) -> Iterable[Tuple[str, Any]]:
kwargs.update(args) kwargs.update(args)
for key, value in kwargs.items(): for key, value in kwargs.items():
if value is not None and value is not False: if value is not None and value is not False:
yield _prefix + key, value yield _prefix + key, value
@staticmethod @staticmethod
def get_prefix(prefix, generator=False): def get_prefix(prefix: str, generator: bool = False) -> str:
return ('g' if generator else '') + prefix return ('g' if generator else '') + prefix
@staticmethod @staticmethod
def get_list(generator=False): def get_list(generator: bool = False) -> Union[Type['GeneratorList'], Type['List']]:
return GeneratorList if generator else List return GeneratorList if generator else List
class NestedList(List): class NestedList(List):
def __init__(self, nested_param, *args, **kwargs): def __init__(self, nested_param: str, *args: Any, **kwargs: Any) -> None:
super(NestedList, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.nested_param = nested_param self.nested_param = nested_param
def set_iter(self, data): def set_iter(self, data: Mapping[str, Any]) -> None:
self._iter = iter(data['query'][self.result_member][self.nested_param]) self._iter = iter(data['query'][self.result_member][self.nested_param])
...@@ -158,9 +175,17 @@ class GeneratorList(List): ...@@ -158,9 +175,17 @@ class GeneratorList(List):
this subclass turns the data into Page, Image or Category objects. this subclass turns the data into Page, Image or Category objects.
""" """
def __init__(self, site, list_name, prefix, *args, **kwargs): def __init__(
super(GeneratorList, self).__init__(site, list_name, prefix, self,
*args, **kwargs) site: 'mwclient.client.Site',
list_name: str,
prefix: str,
*args: Tuple[str, Any],
**kwargs: Any
) -> None:
super().__init__(
site, list_name, prefix, *args, **kwargs # type: ignore[arg-type]
)
self.args['g' + self.prefix + 'limit'] = self.args[self.prefix + 'limit'] self.args['g' + self.prefix + 'limit'] = self.args[self.prefix + 'limit']
del self.args[self.prefix + 'limit'] del self.args[self.prefix + 'limit']
...@@ -173,40 +198,64 @@ class GeneratorList(List): ...@@ -173,40 +198,64 @@ class GeneratorList(List):
self.page_class = mwclient.page.Page self.page_class = mwclient.page.Page
def __next__(self): def __next__(self) -> Union['mwclient.page.Page', 'mwclient.image.Image', 'Category']:
info = super(GeneratorList, self).__next__() info = super().__next__()
if info['ns'] == 14: if info['ns'] == 14:
return Category(self.site, '', info) return Category(self.site, '', info)
if info['ns'] == 6: if info['ns'] == 6:
return mwclient.image.Image(self.site, '', info) return mwclient.image.Image(self.site, '', info)
return mwclient.page.Page(self.site, '', info) return mwclient.page.Page(self.site, '', info)
def load_chunk(self): def load_chunk(self) -> None:
# Put this here so that the constructor does not fail # Put this here so that the constructor does not fail
# on uninitialized sites # on uninitialized sites
self.args['iiprop'] = 'timestamp|user|comment|url|size|sha1|metadata|archivename' self.args['iiprop'] = 'timestamp|user|comment|url|size|sha1|metadata|archivename'
return super(GeneratorList, self).load_chunk() return super().load_chunk()
class Category(mwclient.page.Page, GeneratorList): class Category(mwclient.page.Page, GeneratorList):
"""
Represents a category on a MediaWiki wiki represented by a
:class:`~mwclient.client.Site` object.
Args:
site (mwclient.client.Site): The site object this page belongs to.
name (Union[str, int, Page]): The title of the page, the page ID, or
another :class:`Page` object to copy.
info (Optional[dict]): Page info, if already fetched, e.g., when
iterating over a list of pages. If not provided, the page info
will be fetched from the API.
namespace (Union[int, str, None]): The namespace of the category
members to list.
"""
def __init__(self, site, name, info=None, namespace=None): def __init__(
self,
site: 'mwclient.client.Site',
name: str,
info: Optional[Mapping[str, Any]] = None,
namespace: Optional[Namespace] = None
) -> None:
mwclient.page.Page.__init__(self, site, name, info) mwclient.page.Page.__init__(self, site, name, info)
kwargs = {} kwargs = {} # type: Dict[str, Any]
kwargs['gcmtitle'] = self.name kwargs['gcmtitle'] = self.name
if namespace: if namespace:
kwargs['gcmnamespace'] = namespace kwargs['gcmnamespace'] = namespace
GeneratorList.__init__(self, site, 'categorymembers', 'cm', **kwargs) GeneratorList.__init__(self, site, 'categorymembers', 'cm', **kwargs)
def __repr__(self): def __repr__(self) -> str:
return "<%s object '%s' for %s>" % ( return f"<{self.__class__.__name__} object '{self.name}' for {self.site}>"
self.__class__.__name__,
self.name.encode('utf-8'), def members(
self.site self,
) prop: str = 'ids|title',
namespace: Optional[Namespace] = None,
def members(self, prop='ids|title', namespace=None, sort='sortkey', sort: str = 'sortkey',
dir='asc', start=None, end=None, generator=True): dir: str = 'asc',
start: Optional[str] = None,
end: Optional[str] = None,
generator: bool = True
) -> 'List':
prefix = self.get_prefix('cm', generator) prefix = self.get_prefix('cm', generator)
kwargs = dict(self.generate_kwargs(prefix, prop=prop, namespace=namespace, kwargs = dict(self.generate_kwargs(prefix, prop=prop, namespace=namespace,
sort=sort, dir=dir, start=start, end=end, sort=sort, dir=dir, start=start, end=end,
...@@ -216,8 +265,15 @@ class Category(mwclient.page.Page, GeneratorList): ...@@ -216,8 +265,15 @@ class Category(mwclient.page.Page, GeneratorList):
class PageList(GeneratorList): class PageList(GeneratorList):
def __init__(self, site, prefix=None, start=None, namespace=0, redirects='all', def __init__(
end=None): self,
site: 'mwclient.client.Site',
prefix: Optional[str] = None,
start: Optional[str] = None,
namespace: int = 0,
redirects: str = 'all',
end: Optional[str] = None
):
self.namespace = namespace self.namespace = namespace
kwargs = {} kwargs = {}
...@@ -228,36 +284,44 @@ class PageList(GeneratorList): ...@@ -228,36 +284,44 @@ class PageList(GeneratorList):
if end: if end:
kwargs['gapto'] = end kwargs['gapto'] = end
super(PageList, self).__init__(site, 'allpages', 'ap', super().__init__(site, 'allpages', 'ap', gapnamespace=str(namespace),
gapnamespace=str(namespace), gapfilterredir=redirects, **kwargs)
gapfilterredir=redirects,
**kwargs)
def __getitem__(self, name): def __getitem__(
self, name: Union[str, int, 'mwclient.page.Page']
) -> Union['mwclient.page.Page', 'mwclient.image.Image', 'Category']:
return self.get(name, None) return self.get(name, None)
def get(self, name, info=()): def get(
self,
name: Union[str, int, 'mwclient.page.Page'],
info: Optional[Mapping[str, Any]] = None
) -> Union['mwclient.page.Page', 'mwclient.image.Image', 'Category']:
"""Return the page of name `name` as an object. """Return the page of name `name` as an object.
If self.namespace is not zero, use {namespace}:{name} as the If self.namespace is not zero, use {namespace}:{name} as the
page name, otherwise guess the namespace from the name using page name, otherwise guess the namespace from the name using
`self.guess_namespace`. `self.guess_namespace`.
Args:
name: The name of the page as a string, the page ID as an int, or
another :class:`Page` object.
info: Page info, if already fetched, e.g., when iterating over a
list of pages. If not provided, the page info will be fetched
from the API.
Returns: Returns:
One of Category, Image or Page (default), according to namespace. One of Category, Image or Page (default), according to namespace.
""" """
if self.namespace != 0: if self.namespace != 0:
full_page_name = u"{namespace}:{name}".format( full_page_name = f"{self.site.namespaces[self.namespace]}:{name}" \
namespace=self.site.namespaces[self.namespace], # type: Union[str, int, 'mwclient.page.Page']
name=name,
)
namespace = self.namespace namespace = self.namespace
else: else:
full_page_name = name full_page_name = name
try: if isinstance(name, str):
namespace = self.guess_namespace(name) namespace = self.guess_namespace(name)
except AttributeError: else:
# raised when `namespace` doesn't have a `startswith` attribute
namespace = 0 namespace = 0
cls = { cls = {
...@@ -265,16 +329,16 @@ class PageList(GeneratorList): ...@@ -265,16 +329,16 @@ class PageList(GeneratorList):
6: mwclient.image.Image, 6: mwclient.image.Image,
}.get(namespace, mwclient.page.Page) }.get(namespace, mwclient.page.Page)
return cls(self.site, full_page_name, info) return cls(self.site, full_page_name, info) # type: ignore[no-any-return]
def guess_namespace(self, name): def guess_namespace(self, name: str) -> int:
"""Guess the namespace from name """Guess the namespace from name
If name starts with any of the site's namespaces' names or If name starts with any of the site's namespaces' names or
default_namespaces, use that. Else, return zero. default_namespaces, use that. Else, return zero.
Args: Args:
name (str): The pagename as a string (having `.startswith`) name: The name of the page.
Returns: Returns:
The id of the guessed namespace or zero. The id of the guessed namespace or zero.
...@@ -282,26 +346,34 @@ class PageList(GeneratorList): ...@@ -282,26 +346,34 @@ class PageList(GeneratorList):
for ns in self.site.namespaces: for ns in self.site.namespaces:
if ns == 0: if ns == 0:
continue continue
namespace = '%s:' % self.site.namespaces[ns].replace(' ', '_') namespace = f'{self.site.namespaces[ns].replace(" ", "_")}:'
if name.startswith(namespace): if name.startswith(namespace):
return ns return ns
elif ns in self.site.default_namespaces:
namespace = '%s:' % self.site.default_namespaces[ns].replace(' ', '_')
if name.startswith(namespace):
return ns
return 0 return 0
class PageProperty(List): class PageProperty(List):
def __init__(self, page, prop, prefix, *args, **kwargs): def __init__(
super(PageProperty, self).__init__(page.site, prop, prefix, self,
titles=page.name, page: 'mwclient.page.Page',
*args, **kwargs) prop: str,
prefix: str,
*args: Tuple[str, Any],
**kwargs: Any
) -> None:
super().__init__(
page.site,
prop,
prefix,
titles=page.name,
*args, # type: ignore[arg-type]
**kwargs,
)
self.page = page self.page = page
self.generator = 'prop' self.generator = 'prop'
def set_iter(self, data): def set_iter(self, data: Mapping[str, Any]) -> None:
for page in data['query']['pages'].values(): for page in data['query']['pages'].values():
if page['title'] == self.page.name: if page['title'] == self.page.name:
self._iter = iter(page.get(self.list_name, ())) self._iter = iter(page.get(self.list_name, ()))
...@@ -311,16 +383,21 @@ class PageProperty(List): ...@@ -311,16 +383,21 @@ class PageProperty(List):
class PagePropertyGenerator(GeneratorList): class PagePropertyGenerator(GeneratorList):
def __init__(self, page, prop, prefix, *args, **kwargs): def __init__(
super(PagePropertyGenerator, self).__init__(page.site, prop, prefix, self,
titles=page.name, page: 'mwclient.page.Page',
*args, **kwargs) prop: str,
prefix: str,
*args: Tuple[str, Any],
**kwargs: Any
) -> None:
super().__init__(page.site, prop, prefix, titles=page.name, *args, **kwargs)
self.page = page self.page = page
class RevisionsIterator(PageProperty): class RevisionsIterator(PageProperty):
def load_chunk(self): def load_chunk(self) -> None:
if 'rvstartid' in self.args and 'rvstart' in self.args: if 'rvstartid' in self.args and 'rvstart' in self.args:
del self.args['rvstart'] del self.args['rvstart']
return super(RevisionsIterator, self).load_chunk() return super().load_chunk()
import time import time
from mwclient.util import parse_timestamp from typing import ( # noqa: F401
import mwclient.listing Optional, Mapping, Any, cast, Dict, Union, Tuple, Iterable, List, NoReturn
)
import mwclient.errors import mwclient.errors
import mwclient.listing
from mwclient._types import Namespace
from mwclient.util import parse_timestamp, handle_limit
class Page: class Page:
"""
def __init__(self, site, name, info=None, extra_properties=None): Represents a page on a MediaWiki wiki represented by a
:class:`~mwclient.client.Site` object.
Args:
site (mwclient.client.Site): The site object this page belongs to.
name (Union[str, int, Page]): The title of the page, the page ID, or
another :class:`Page` object to copy.
info (Optional[dict]): Page info, if already fetched, e.g., when
iterating over a list of pages. If not provided, the page info
will be fetched from the API.
extra_properties (Optional[dict]): Extra properties to fetch when
initializing the page.
Examples:
>>> site = mwclient.Site('en.wikipedia.org')
>>> page1 = Page(site, 'Main Page')
>>> page2 = Page(site, 123456)
>>> page3 = Page(site, 'Main Page', extra_properties={
... 'imageinfo': [
... ('iiprop', 'timestamp|user|comment|url|size|sha1|metadata'),
... ],
... })
"""
def __init__(
self,
site: 'mwclient.client.Site',
name: Union[int, str, 'Page'],
info: Optional[Mapping[str, Any]] = None,
extra_properties: Optional[Mapping[str, Iterable[Tuple[str, str]]]] = None
) -> None:
if type(name) is type(self): if type(name) is type(self):
self.__dict__.update(name.__dict__) self.__dict__.update(name.__dict__)
return return
self.site = site self.site = site
self.name = name self._textcache = {} # type: Dict[int, str]
self._textcache = {}
if not info: if not info:
if extra_properties: if extra_properties:
prop = 'info|' + '|'.join(extra_properties.keys()) prop = 'info|' + '|'.join(extra_properties.keys())
extra_props = [] extra_props = [] # type: List[Tuple[str, str]]
for extra_prop in extra_properties.values(): for extra_prop in extra_properties.values():
extra_props.extend(extra_prop) extra_props.extend(extra_prop)
else: else:
prop = 'info' prop = 'info'
extra_props = () extra_props = []
if type(name) is int: if type(name) is int:
info = self.site.get('query', prop=prop, pageids=name, info = self.site.get('query', prop=prop, pageids=name,
...@@ -31,21 +65,15 @@ class Page: ...@@ -31,21 +65,15 @@ class Page:
info = self.site.get('query', prop=prop, titles=name, info = self.site.get('query', prop=prop, titles=name,
inprop='protection', *extra_props) inprop='protection', *extra_props)
info = next(iter(info['query']['pages'].values())) info = next(iter(info['query']['pages'].values()))
info = cast(Mapping[str, Any], info)
self._info = info self._info = info
if 'invalid' in info: if 'invalid' in info:
raise mwclient.errors.InvalidPageTitle(info.get('invalidreason')) raise mwclient.errors.InvalidPageTitle(info.get('invalidreason'))
self.namespace = info.get('ns', 0) self.namespace = info.get('ns', 0)
self.name = info.get('title', '') self.name = info.get('title', '') # type: str
if self.namespace:
self.page_title = self.strip_namespace(self.name)
else:
self.page_title = self.name
self.base_title = self.page_title.split('/')[0]
self.base_name = self.name.split('/')[0]
self.touched = parse_timestamp(info.get('touched')) self.touched = parse_timestamp(info.get('touched'))
self.revision = info.get('lastrevid', 0) self.revision = info.get('lastrevid', 0)
self.exists = 'missing' not in info self.exists = 'missing' not in info
...@@ -61,10 +89,25 @@ class Page: ...@@ -61,10 +89,25 @@ class Page:
self.pagelanguage = info.get('pagelanguage', None) self.pagelanguage = info.get('pagelanguage', None)
self.restrictiontypes = info.get('restrictiontypes', None) self.restrictiontypes = info.get('restrictiontypes', None)
self.last_rev_time = None self.last_rev_time = None # type: Optional[time.struct_time]
self.edit_time = None self.edit_time = None # type: Optional[time.struct_time]
@property
def page_title(self) -> str:
if self.namespace:
return self.strip_namespace(self.name)
else:
return self.name
@property
def base_title(self) -> str:
return self.page_title.split('/')[0]
def redirects_to(self): @property
def base_name(self) -> str:
return self.name.split('/')[0]
def redirects_to(self) -> Optional['Page']:
""" Get the redirect target page, or None if the page is not a redirect.""" """ Get the redirect target page, or None if the page is not a redirect."""
info = self.site.get('query', prop='pageprops', titles=self.name, redirects='') info = self.site.get('query', prop='pageprops', titles=self.name, redirects='')
if 'redirects' in info['query']: if 'redirects' in info['query']:
...@@ -75,7 +118,7 @@ class Page: ...@@ -75,7 +118,7 @@ class Page:
else: else:
return None return None
def resolve_redirect(self): def resolve_redirect(self) -> 'Page':
""" Get the redirect target page, or the current page if its not a redirect.""" """ Get the redirect target page, or the current page if its not a redirect."""
target_page = self.redirects_to() target_page = self.redirects_to()
if target_page is None: if target_page is None:
...@@ -83,21 +126,17 @@ class Page: ...@@ -83,21 +126,17 @@ class Page:
else: else:
return target_page return target_page
def __repr__(self): def __repr__(self) -> str:
return "<%s object '%s' for %s>" % ( return f"<{self.__class__.__name__} object '{self.name}' for {self.site}>"
self.__class__.__name__,
self.name.encode('utf-8'),
self.site
)
@staticmethod @staticmethod
def strip_namespace(title): def strip_namespace(title: str) -> str:
if title[0] == ':': if title[0] == ':':
title = title[1:] title = title[1:]
return title[title.find(':') + 1:] return title[title.find(':') + 1:]
@staticmethod @staticmethod
def normalize_title(title): def normalize_title(title: str) -> str:
# TODO: Make site dependent # TODO: Make site dependent
title = title.strip() title = title.strip()
if title[0] == ':': if title[0] == ':':
...@@ -106,7 +145,7 @@ class Page: ...@@ -106,7 +145,7 @@ class Page:
title = title.replace(' ', '_') title = title.replace(' ', '_')
return title return title
def can(self, action): def can(self, action: str) -> bool:
"""Check if the current user has the right to carry out some action """Check if the current user has the right to carry out some action
with the current page. with the current page.
...@@ -121,10 +160,16 @@ class Page: ...@@ -121,10 +160,16 @@ class Page:
return level in self.site.rights return level in self.site.rights
def get_token(self, type, force=False): def get_token(self, type: str, force: bool = False) -> str:
return self.site.get_token(type, force, title=self.name) return self.site.get_token(type, force, title=self.name)
def text(self, section=None, expandtemplates=False, cache=True, slot='main'): def text(
self,
section: Union[int, str, None] = None,
expandtemplates: bool = False,
cache: bool = True,
slot: str = 'main'
) -> Any:
"""Get the current wikitext of the page, or of a specific section. """Get the current wikitext of the page, or of a specific section.
If the page does not exist, an empty string is returned. By If the page does not exist, an empty string is returned. By
...@@ -134,9 +179,9 @@ class Page: ...@@ -134,9 +179,9 @@ class Page:
lives as long as the instance does. lives as long as the instance does.
Args: Args:
section (int): Section number, to only get text from a single section. section: Section number, to only get text from a single section.
expandtemplates (bool): Expand templates (default: `False`) expandtemplates: Expand templates (default: `False`)
cache (bool): Use in-memory caching (default: `True`) cache: Use in-memory caching (default: `True`)
""" """
if not self.can('read'): if not self.can('read'):
...@@ -150,7 +195,10 @@ class Page: ...@@ -150,7 +195,10 @@ class Page:
if cache and key in self._textcache: if cache and key in self._textcache:
return self._textcache[key] return self._textcache[key]
revs = self.revisions(prop='content|timestamp', limit=1, section=section, # we set api_chunk_size not max_items because otherwise revisions'
# default api_chunk_size of 50 gets used and we get 50 revisions;
# no need to set max_items as well as we only iterate one time
revs = self.revisions(prop='content|timestamp', api_chunk_size=1, section=section,
slots=slot) slots=slot)
try: try:
rev = next(revs) rev = next(revs)
...@@ -173,28 +221,52 @@ class Page: ...@@ -173,28 +221,52 @@ class Page:
self._textcache[key] = text self._textcache[key] = text
return text return text
def save(self, *args, **kwargs): def save(self, *args: Tuple[str, Any], **kwargs: Any) -> Any:
"""Alias for edit, for maintaining backwards compatibility.""" """Alias for edit, for maintaining backwards compatibility."""
return self.edit(*args, **kwargs) return self.edit(*args, **kwargs) # type: ignore[arg-type]
def edit(self, text, summary='', minor=False, bot=True, section=None, **kwargs): def edit(
self,
text: str,
summary: str = '',
minor: bool = False,
bot: bool = True,
section: Optional[str] = None,
**kwargs: Any
) -> Any:
"""Update the text of a section or the whole page by performing an edit operation. """Update the text of a section or the whole page by performing an edit operation.
""" """
return self._edit(summary, minor, bot, section, text=text, **kwargs) return self._edit(summary, minor, bot, section, text=text, **kwargs)
def append(self, text, summary='', minor=False, bot=True, section=None, def append(
**kwargs): self,
text: str,
summary: str = '',
minor: bool = False,
bot: bool = True,
section: Optional[str] = None,
**kwargs: Any
) -> Any:
"""Append text to a section or the whole page by performing an edit operation. """Append text to a section or the whole page by performing an edit operation.
""" """
return self._edit(summary, minor, bot, section, appendtext=text, **kwargs) return self._edit(summary, minor, bot, section, appendtext=text, **kwargs)
def prepend(self, text, summary='', minor=False, bot=True, section=None, def prepend(
**kwargs): self,
text: str,
summary: str = '',
minor: bool = False,
bot: bool = True,
section: Optional[str] = None,
**kwargs: Any
) -> Any:
"""Prepend text to a section or the whole page by performing an edit operation. """Prepend text to a section or the whole page by performing an edit operation.
""" """
return self._edit(summary, minor, bot, section, prependtext=text, **kwargs) return self._edit(summary, minor, bot, section, prependtext=text, **kwargs)
def _edit(self, summary, minor, bot, section, **kwargs): def _edit(
self, summary: str, minor: bool, bot: bool, section: Optional[str], **kwargs: Any
) -> Any:
if not self.site.logged_in and self.site.force_login: if not self.site.logged_in and self.site.force_login:
raise mwclient.errors.AssertUserFailedError() raise mwclient.errors.AssertUserFailedError()
if self.site.blocked: if self.site.blocked:
...@@ -202,9 +274,6 @@ class Page: ...@@ -202,9 +274,6 @@ class Page:
if not self.can('edit'): if not self.can('edit'):
raise mwclient.errors.ProtectedPageError(self) raise mwclient.errors.ProtectedPageError(self)
if not self.site.writeapi:
raise mwclient.errors.NoWriteApi(self)
data = {} data = {}
if minor: if minor:
data['minor'] = '1' data['minor'] = '1'
...@@ -224,7 +293,7 @@ class Page: ...@@ -224,7 +293,7 @@ class Page:
if self.site.force_login: if self.site.force_login:
data['assert'] = 'user' data['assert'] = 'user'
def do_edit(): def do_edit() -> Dict[str, Any]:
result = self.site.post('edit', title=self.name, summary=summary, result = self.site.post('edit', title=self.name, summary=summary,
token=self.get_token('edit'), token=self.get_token('edit'),
**data) **data)
...@@ -240,14 +309,21 @@ class Page: ...@@ -240,14 +309,21 @@ class Page:
self.get_token('edit', force=True) self.get_token('edit', force=True)
try: try:
result = do_edit() result = do_edit()
except mwclient.errors.APIError as e: except mwclient.errors.APIError as e2:
self.handle_edit_error(e, summary) self.handle_edit_error(e2, summary)
else: else:
self.handle_edit_error(e, summary) self.handle_edit_error(e, summary)
self.exists = True
self.name = result['edit'].get('title', self.name)
self.pageid = result['edit'].get('pageid', self.pageid)
self.revision = result['edit'].get('newrevid', self.revision)
self.contentmodel = result['edit'].get('contentmodel', self.contentmodel)
# 'newtimestamp' is not included if no change was made # 'newtimestamp' is not included if no change was made
if 'newtimestamp' in result['edit'].keys(): if 'newtimestamp' in result['edit'].keys():
self.last_rev_time = parse_timestamp(result['edit'].get('newtimestamp')) new_timestamp = parse_timestamp(result['edit'].get('newtimestamp'))
self.last_rev_time = new_timestamp
self.touched = new_timestamp
# Workaround for https://phabricator.wikimedia.org/T211233 # Workaround for https://phabricator.wikimedia.org/T211233
for cookie in self.site.connection.cookies: for cookie in self.site.connection.cookies:
...@@ -259,7 +335,7 @@ class Page: ...@@ -259,7 +335,7 @@ class Page:
self._textcache = {} self._textcache = {}
return result['edit'] return result['edit']
def handle_edit_error(self, e, summary): def handle_edit_error(self, e: 'mwclient.errors.APIError', summary: str) -> NoReturn:
if e.code == 'editconflict': if e.code == 'editconflict':
raise mwclient.errors.EditError(self, summary, e.info) raise mwclient.errors.EditError(self, summary, e.info)
elif e.code in {'protectedtitle', 'cantcreate', 'cantcreate-anon', elif e.code in {'protectedtitle', 'cantcreate', 'cantcreate-anon',
...@@ -273,7 +349,7 @@ class Page: ...@@ -273,7 +349,7 @@ class Page:
else: else:
raise e raise e
def touch(self): def touch(self) -> None:
"""Perform a "null edit" on the page to update the wiki's cached data of it. """Perform a "null edit" on the page to update the wiki's cached data of it.
This is useful in contrast to purge when needing to update stored data on a wiki, This is useful in contrast to purge when needing to update stored data on a wiki,
for example Semantic MediaWiki properties or Cargo table values, since purge for example Semantic MediaWiki properties or Cargo table values, since purge
...@@ -283,8 +359,15 @@ class Page: ...@@ -283,8 +359,15 @@ class Page:
return return
self.append('') self.append('')
def move(self, new_title, reason='', move_talk=True, no_redirect=False, def move(
move_subpages=False, ignore_warnings=False): self,
new_title: str,
reason: str = '',
move_talk: bool = True,
no_redirect: bool = False,
move_subpages: bool = False,
ignore_warnings: bool = False
) -> Any:
"""Move (rename) page to new_title. """Move (rename) page to new_title.
If user account is an administrator, specify no_redirect as True to not If user account is an administrator, specify no_redirect as True to not
...@@ -293,13 +376,11 @@ class Page: ...@@ -293,13 +376,11 @@ class Page:
If user does not have permission to move page, an InsufficientPermission If user does not have permission to move page, an InsufficientPermission
exception is raised. exception is raised.
API doc: https://www.mediawiki.org/wiki/API:Move
""" """
if not self.can('move'): if not self.can('move'):
raise mwclient.errors.InsufficientPermission(self) raise mwclient.errors.InsufficientPermission(self)
if not self.site.writeapi:
raise mwclient.errors.NoWriteApi(self)
data = {} data = {}
if move_talk: if move_talk:
data['movetalk'] = '1' data['movetalk'] = '1'
...@@ -309,23 +390,34 @@ class Page: ...@@ -309,23 +390,34 @@ class Page:
data['movesubpages'] = '1' data['movesubpages'] = '1'
if ignore_warnings: if ignore_warnings:
data['ignorewarnings'] = '1' data['ignorewarnings'] = '1'
result = self.site.post('move', ('from', self.name), to=new_title, result = self.site.post('move', ('from', self.name), to=new_title,
token=self.get_token('move'), reason=reason, **data) token=self.get_token('move'), reason=reason, **data)
if 'redirectcreated' in result['move']:
self.redirect = True
else:
self.exists = False
return result['move'] return result['move']
def delete(self, reason='', watch=False, unwatch=False, oldimage=False): def delete(
self,
reason: str = '',
watch: bool = False,
unwatch: bool = False,
oldimage: Optional[str] = None
) -> Any:
"""Delete page. """Delete page.
If user does not have permission to delete page, an InsufficientPermission If user does not have permission to delete page, an InsufficientPermission
exception is raised. exception is raised.
API doc: https://www.mediawiki.org/wiki/API:Delete
""" """
if not self.can('delete'): if not self.can('delete'):
raise mwclient.errors.InsufficientPermission(self) raise mwclient.errors.InsufficientPermission(self)
if not self.site.writeapi:
raise mwclient.errors.NoWriteApi(self)
data = {} data = {}
if watch: if watch:
data['watch'] = '1' data['watch'] = '1'
...@@ -333,49 +425,64 @@ class Page: ...@@ -333,49 +425,64 @@ class Page:
data['unwatch'] = '1' data['unwatch'] = '1'
if oldimage: if oldimage:
data['oldimage'] = oldimage data['oldimage'] = oldimage
result = self.site.post('delete', title=self.name, result = self.site.post('delete', title=self.name,
token=self.get_token('delete'), token=self.get_token('delete'),
reason=reason, **data) reason=reason, **data)
self.exists = False
return result['delete'] return result['delete']
def purge(self): def purge(self) -> None:
"""Purge server-side cache of page. This will re-render templates and other """Purge server-side cache of page. This will re-render templates and other
dynamic content. dynamic content.
API doc: https://www.mediawiki.org/wiki/API:Purge
""" """
self.site.post('purge', titles=self.name) self.site.post('purge', titles=self.name)
# def watch: requires 1.14 # def watch: requires 1.14
# Properties # Properties
def backlinks(self, namespace=None, filterredir='all', redirect=False, def backlinks(
limit=None, generator=True): self,
namespace: Optional[Namespace] = None,
filterredir: str = 'all',
redirect: bool = False,
limit: Optional[int] = None,
generator: bool = True,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> 'mwclient.listing.List':
"""List pages that link to the current page, similar to Special:Whatlinkshere. """List pages that link to the current page, similar to Special:Whatlinkshere.
API doc: https://www.mediawiki.org/wiki/API:Backlinks API doc: https://www.mediawiki.org/wiki/API:Backlinks
""" """
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
prefix = mwclient.listing.List.get_prefix('bl', generator) prefix = mwclient.listing.List.get_prefix('bl', generator)
kwargs = dict(mwclient.listing.List.generate_kwargs( kwargs = dict(mwclient.listing.List.generate_kwargs(
prefix, namespace=namespace, filterredir=filterredir, prefix, namespace=namespace, filterredir=filterredir,
)) ))
if redirect: if redirect:
kwargs['%sredirect' % prefix] = '1' kwargs[f'{prefix}redirect'] = '1'
kwargs[prefix + 'title'] = self.name kwargs[prefix + 'title'] = self.name
return mwclient.listing.List.get_list(generator)( return mwclient.listing.List.get_list(generator)(
self.site, 'backlinks', 'bl', limit=limit, return_values='title', self.site, 'backlinks', 'bl', max_items=max_items,
**kwargs api_chunk_size=api_chunk_size, return_values='title', **kwargs
) )
def categories(self, generator=True, show=None): def categories(
self, generator: bool = True, show: Optional[str] = None
) -> Union['mwclient.listing.PagePropertyGenerator', 'mwclient.listing.PageProperty']:
"""List categories used on the current page. """List categories used on the current page.
API doc: https://www.mediawiki.org/wiki/API:Categories API doc: https://www.mediawiki.org/wiki/API:Categories
Args: Args:
generator (bool): Return generator (Default: True) generator: Return generator (Default: True)
show (str): Set to 'hidden' to only return hidden categories show: Set to 'hidden' to only return hidden categories
or '!hidden' to only return non-hidden ones. or '!hidden' to only return non-hidden ones.
Returns: Returns:
...@@ -396,32 +503,43 @@ class Page: ...@@ -396,32 +503,43 @@ class Page:
self, 'categories', 'cl', return_values='title', **kwargs self, 'categories', 'cl', return_values='title', **kwargs
) )
def embeddedin(self, namespace=None, filterredir='all', limit=None, generator=True): def embeddedin(
self,
namespace: Optional[Namespace] = None,
filterredir: str = 'all',
limit: Optional[int] = None,
generator: bool = True,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = None
) -> 'mwclient.listing.List':
"""List pages that transclude the current page. """List pages that transclude the current page.
API doc: https://www.mediawiki.org/wiki/API:Embeddedin API doc: https://www.mediawiki.org/wiki/API:Embeddedin
Args: Args:
namespace (int): Restricts search to a given namespace (Default: None) namespace Restricts search to a given namespace (Default: None)
filterredir (str): How to filter redirects, either 'all' (default), filterredir: How to filter redirects, either 'all' (default),
'redirects' or 'nonredirects'. 'redirects' or 'nonredirects'.
limit (int): Maximum amount of pages to return per request limit: The API request chunk size (deprecated)
generator (bool): Return generator (Default: True) generator: Return generator (Default: True)
max_items: The maximum number of pages to yield
api_chunk_size: The API request chunk size
Returns: Returns:
mwclient.listings.List: Page iterator mwclient.listings.List: Page iterator
""" """
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
prefix = mwclient.listing.List.get_prefix('ei', generator) prefix = mwclient.listing.List.get_prefix('ei', generator)
kwargs = dict(mwclient.listing.List.generate_kwargs(prefix, namespace=namespace, kwargs = dict(mwclient.listing.List.generate_kwargs(prefix, namespace=namespace,
filterredir=filterredir)) filterredir=filterredir))
kwargs[prefix + 'title'] = self.name kwargs[prefix + 'title'] = self.name
return mwclient.listing.List.get_list(generator)( return mwclient.listing.List.get_list(generator)(
self.site, 'embeddedin', 'ei', limit=limit, return_values='title', self.site, 'embeddedin', 'ei', max_items=max_items,
**kwargs api_chunk_size=api_chunk_size, return_values='title', **kwargs
) )
def extlinks(self): def extlinks(self) -> 'mwclient.listing.PageProperty':
"""List external links from the current page. """List external links from the current page.
API doc: https://www.mediawiki.org/wiki/API:Extlinks API doc: https://www.mediawiki.org/wiki/API:Extlinks
...@@ -429,7 +547,9 @@ class Page: ...@@ -429,7 +547,9 @@ class Page:
""" """
return mwclient.listing.PageProperty(self, 'extlinks', 'el', return_values='*') return mwclient.listing.PageProperty(self, 'extlinks', 'el', return_values='*')
def images(self, generator=True): def images(
self, generator: bool = True
) -> Union['mwclient.listing.PagePropertyGenerator', 'mwclient.listing.PageProperty']:
"""List files/images embedded in the current page. """List files/images embedded in the current page.
API doc: https://www.mediawiki.org/wiki/API:Images API doc: https://www.mediawiki.org/wiki/API:Images
...@@ -441,7 +561,7 @@ class Page: ...@@ -441,7 +561,7 @@ class Page:
return mwclient.listing.PageProperty(self, 'images', '', return mwclient.listing.PageProperty(self, 'images', '',
return_values='title') return_values='title')
def iwlinks(self): def iwlinks(self) -> 'mwclient.listing.PageProperty':
"""List interwiki links from the current page. """List interwiki links from the current page.
API doc: https://www.mediawiki.org/wiki/API:Iwlinks API doc: https://www.mediawiki.org/wiki/API:Iwlinks
...@@ -450,7 +570,7 @@ class Page: ...@@ -450,7 +570,7 @@ class Page:
return mwclient.listing.PageProperty(self, 'iwlinks', 'iw', return mwclient.listing.PageProperty(self, 'iwlinks', 'iw',
return_values=('prefix', '*')) return_values=('prefix', '*'))
def langlinks(self, **kwargs): def langlinks(self, **kwargs: Any) -> 'mwclient.listing.PageProperty':
"""List interlanguage links from the current page. """List interlanguage links from the current page.
API doc: https://www.mediawiki.org/wiki/API:Langlinks API doc: https://www.mediawiki.org/wiki/API:Langlinks
...@@ -460,7 +580,12 @@ class Page: ...@@ -460,7 +580,12 @@ class Page:
return_values=('lang', '*'), return_values=('lang', '*'),
**kwargs) **kwargs)
def links(self, namespace=None, generator=True, redirects=False): def links(
self,
namespace: Optional[Namespace] = None,
generator: bool = True,
redirects: bool = False
) -> Union['mwclient.listing.PagePropertyGenerator', 'mwclient.listing.PageProperty']:
"""List links to other pages from the current page. """List links to other pages from the current page.
API doc: https://www.mediawiki.org/wiki/API:Links API doc: https://www.mediawiki.org/wiki/API:Links
...@@ -477,44 +602,61 @@ class Page: ...@@ -477,44 +602,61 @@ class Page:
return mwclient.listing.PageProperty(self, 'links', 'pl', return mwclient.listing.PageProperty(self, 'links', 'pl',
return_values='title', **kwargs) return_values='title', **kwargs)
def revisions(self, startid=None, endid=None, start=None, end=None, def revisions(
dir='older', user=None, excludeuser=None, limit=50, self,
prop='ids|timestamp|flags|comment|user', startid: Optional[int] = None,
expandtemplates=False, section=None, endid: Optional[int] = None,
diffto=None, slots=None, uselang=None): start: Optional[str] = None,
end: Optional[str] = None,
dir: str = 'older',
user: Optional[str] = None,
excludeuser: Optional[str] = None,
limit: Optional[int] = None,
prop: str = 'ids|timestamp|flags|comment|user',
expandtemplates: bool = False,
section: Optional[str] = None,
diffto: Optional[int] = None,
slots: Optional[str] = None,
uselang: Optional[str] = None,
max_items: Optional[int] = None,
api_chunk_size: Optional[int] = 50
) -> 'mwclient.listing.List':
"""List revisions of the current page. """List revisions of the current page.
API doc: https://www.mediawiki.org/wiki/API:Revisions API doc: https://www.mediawiki.org/wiki/API:Revisions
Args: Args:
startid (int): Revision ID to start listing from. startid: Revision ID to start listing from.
endid (int): Revision ID to stop listing at. endid: Revision ID to stop listing at.
start (str): Timestamp to start listing from. start: Timestamp to start listing from.
end (str): Timestamp to end listing at. end: Timestamp to end listing at.
dir (str): Direction to list in: 'older' (default) or 'newer'. dir: Direction to list in: 'older' (default) or 'newer'.
user (str): Only list revisions made by this user. user: Only list revisions made by this user.
excludeuser (str): Exclude revisions made by this user. excludeuser: Exclude revisions made by this user.
limit (int): The maximum number of revisions to return per request. limit: The API request chunk size (deprecated).
prop (str): Which properties to get for each revision, prop: Which properties to get for each revision,
default: 'ids|timestamp|flags|comment|user' default: 'ids|timestamp|flags|comment|user'
expandtemplates (bool): Expand templates in rvprop=content output expandtemplates: Expand templates in rvprop=content output
section (int): Section number. If rvprop=content is set, only the contents section: Section number. If rvprop=content is set, only the contents
of this section will be retrieved. of this section will be retrieved.
diffto (str): Revision ID to diff each revision to. Use "prev", "next" and diffto: Revision ID to diff each revision to. Use "prev", "next" and
"cur" for the previous, next and current revision respectively. "cur" for the previous, next and current revision respectively.
slots (str): The content slot (Mediawiki >= 1.32) to retrieve content from. slots: The content slot (Mediawiki >= 1.32) to retrieve content from.
uselang (str): Language to use for parsed edit comments and other localized uselang: Language to use for parsed edit comments and other localized
messages. messages.
max_items: The maximum number of revisions to yield.
api_chunk_size: The API request chunk size (as a number of revisions).
Returns: Returns:
mwclient.listings.List: Revision iterator mwclient.listings.List: Revision iterator
""" """
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(mwclient.listing.List.generate_kwargs( kwargs = dict(mwclient.listing.List.generate_kwargs(
'rv', startid=startid, endid=endid, start=start, end=end, user=user, 'rv', startid=startid, endid=endid, start=start, end=end, user=user,
excludeuser=excludeuser, diffto=diffto, slots=slots excludeuser=excludeuser, diffto=diffto, slots=slots
)) ))
if self.site.version[:2] < (1, 32) and 'rvslots' in kwargs: if self.site.version[:2] < (1, 32) and 'rvslots' in kwargs: # type: ignore[index]
# https://github.com/mwclient/mwclient/issues/199 # https://github.com/mwclient/mwclient/issues/199
del kwargs['rvslots'] del kwargs['rvslots']
...@@ -526,10 +668,14 @@ class Page: ...@@ -526,10 +668,14 @@ class Page:
if section is not None: if section is not None:
kwargs['rvsection'] = section kwargs['rvsection'] = section
return mwclient.listing.RevisionsIterator(self, 'revisions', 'rv', limit=limit, return mwclient.listing.RevisionsIterator(self, 'revisions', 'rv',
max_items=max_items,
api_chunk_size=api_chunk_size,
**kwargs) **kwargs)
def templates(self, namespace=None, generator=True): def templates(
self, namespace: Optional[Namespace] = None, generator: bool = True
) -> Union['mwclient.listing.PagePropertyGenerator', 'mwclient.listing.PageProperty']:
"""List templates used on the current page. """List templates used on the current page.
API doc: https://www.mediawiki.org/wiki/API:Templates API doc: https://www.mediawiki.org/wiki/API:Templates
......
import time
import logging import logging
import time
from typing import Callable, Optional, Any
from mwclient.errors import MaximumRetriesExceeded from mwclient.errors import MaximumRetriesExceeded
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -17,24 +19,30 @@ class Sleepers: ...@@ -17,24 +19,30 @@ class Sleepers:
using the `make` method. using the `make` method.
>>> sleeper = sleepers.make() >>> sleeper = sleepers.make()
Args: Args:
max_retries (int): The maximum number of retries to perform. max_retries: The maximum number of retries to perform.
retry_timeout (int): The time to sleep for each past retry. retry_timeout: The time to sleep for each past retry.
callback (Callable[[int, Any], None]): A callable to be called on each retry. callback: A callable to be called on each retry.
Attributes: Attributes:
max_retries (int): The maximum number of retries to perform. max_retries: The maximum number of retries to perform.
retry_timeout (int): The time to sleep for each past retry. retry_timeout: The time to sleep for each past retry.
callback (callable): A callable to be called on each retry. callback: A callable to be called on each retry.
""" """
def __init__(self, max_retries, retry_timeout, callback=lambda *x: None):
def __init__(
self,
max_retries: int,
retry_timeout: int,
callback: Callable[['Sleeper', int, Optional[Any]], Any] = lambda *x: None
) -> None:
self.max_retries = max_retries self.max_retries = max_retries
self.retry_timeout = retry_timeout self.retry_timeout = retry_timeout
self.callback = callback self.callback = callback
def make(self, args=None): def make(self, args: Optional[Any] = None) -> 'Sleeper':
""" """
Creates a new `Sleeper` object. Creates a new `Sleeper` object.
Args: Args:
args (Any): Arguments to be passed to the `callback` callable. args: Arguments to be passed to the `callback` callable.
Returns: Returns:
Sleeper: A `Sleeper` object. Sleeper: A `Sleeper` object.
""" """
...@@ -48,25 +56,32 @@ class Sleeper: ...@@ -48,25 +56,32 @@ class Sleeper:
and a `MaximumRetriesExceeded` is raised. The sleeper object should be discarded and a `MaximumRetriesExceeded` is raised. The sleeper object should be discarded
once the operation is successful. once the operation is successful.
Args: Args:
args (Any): Arguments to be passed to the `callback` callable. args: Arguments to be passed to the `callback` callable.
max_retries (int): The maximum number of retries to perform. max_retries: The maximum number of retries to perform.
retry_timeout (int): The time to sleep for each past retry. retry_timeout: The time to sleep for each past retry.
callback (callable, None]): A callable to be called on each retry. callback: A callable to be called on each retry.
Attributes: Attributes:
args (Any): Arguments to be passed to the `callback` callable. args: Arguments to be passed to the `callback` callable.
retries (int): The number of retries that have been performed. retries: The number of retries that have been performed.
max_retries (int): The maximum number of retries to perform. max_retries: The maximum number of retries to perform.
retry_timeout (int): The time to sleep for each past retry. retry_timeout: The time to sleep for each past retry.
callback (callable): A callable to be called on each retry. callback: A callable to be called on each retry.
""" """
def __init__(self, args, max_retries, retry_timeout, callback):
def __init__(
self,
args: Any,
max_retries: int,
retry_timeout: int,
callback: Callable[['Sleeper', int, Optional[Any]], Any]
) -> None:
self.args = args self.args = args
self.retries = 0 self.retries = 0
self.max_retries = max_retries self.max_retries = max_retries
self.retry_timeout = retry_timeout self.retry_timeout = retry_timeout
self.callback = callback self.callback = callback
def sleep(self, min_time=0): def sleep(self, min_time: int = 0) -> None:
""" """
Sleeps for a minimum of `min_time` seconds. The actual sleeping time will increase Sleeps for a minimum of `min_time` seconds. The actual sleeping time will increase
with the number of retries. with the number of retries.
......
import time import time
import io import io
from typing import Optional, Iterable, Tuple, BinaryIO
import warnings
def parse_timestamp(t): def parse_timestamp(t: Optional[str]) -> time.struct_time:
"""Parses a string containing a timestamp. """Parses a string containing a timestamp.
Args: Args:
t (str): A string containing a timestamp. t: A string containing a timestamp.
Returns: Returns:
time.struct_time: A timestamp. time.struct_time: A timestamp.
...@@ -16,9 +18,38 @@ def parse_timestamp(t): ...@@ -16,9 +18,38 @@ def parse_timestamp(t):
return time.strptime(t, '%Y-%m-%dT%H:%M:%SZ') return time.strptime(t, '%Y-%m-%dT%H:%M:%SZ')
def read_in_chunks(stream, chunk_size): def read_in_chunks(stream: BinaryIO, chunk_size: int) -> Iterable[io.BytesIO]:
while True: while True:
data = stream.read(chunk_size) data = stream.read(chunk_size)
if not data: if not data:
break break
yield io.BytesIO(data) yield io.BytesIO(data)
def handle_limit(
limit: Optional[int], max_items: Optional[int], api_chunk_size: Optional[int]
) -> Tuple[Optional[int], Optional[int]]:
"""
Consistently handles 'limit', 'api_chunk_size' and 'max_items' -
https://github.com/mwclient/mwclient/issues/259 . In version 0.11,
'api_chunk_size' was introduced as a better name for 'limit', but
we still accept 'limit' with a deprecation warning. 'max_items'
does what 'limit' sounds like it should.
"""
if limit:
if api_chunk_size:
warnings.warn(
"limit and api_chunk_size both specified, this is not supported! limit "
"is deprecated, will use value of api_chunk_size",
DeprecationWarning
)
else:
warnings.warn(
"limit is deprecated as its name and purpose are confusing. use "
"api_chunk_size to set the number of items retrieved from the API at "
"once, and/or max_items to limit the total number of items that will be "
"yielded",
DeprecationWarning
)
api_chunk_size = limit
return (max_items, api_chunk_size)
[project]
name = "mwclient"
dynamic = ["version"]
description = "MediaWiki API client"
readme = "README.md"
requires-python = ">=3.6"
authors = [
{ name = "Bryan Tong Minh", email = "bryan.tongminh@gmail.com" },
]
keywords = ["mediawiki", "wikipedia"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"requests",
"requests-oauthlib",
]
[project.optional-dependencies]
docs = [
"sphinx",
"sphinx-rtd-theme",
]
testing = [
"pytest",
"pytest-cov",
"responses>=0.3.0",
"responses!=0.6.0",
"setuptools; python_version < '3.8'",
]
[project.urls]
Documentation = "https://mwclient.readthedocs.io/"
Repository = "https://github.com/mwclient/mwclient"
Issues = "https://github.com/mwclient/mwclient/issues"
Changelog = "https://github.com/mwclient/mwclient/releases"
[build-system] [build-system]
requires = ["setuptools>=40.6.0", "wheel"] requires = ["hatchling"]
build-backend = "setuptools.build_meta" build-backend = "hatchling.build"
[tool.pytest.ini_options] [tool.hatch.version]
addopts = "--cov mwclient test" path = "mwclient/client.py"
[tool.hatch.build.targets.sdist]
exclude = [
"/.flake8",
"/.gitattributes",
"/.github",
"/.readthedocs.yaml",
]
[tool.bumpversion] [tool.bumpversion]
current_version = "0.10.1" current_version = "0.11.0"
commit = true commit = true
tag = true tag = true
[[tool.bumpversion.files]]
filename = "setup.py"
search = "version='{current_version}'"
replace = "version='{new_version}'"
[[tool.bumpversion.files]] [[tool.bumpversion.files]]
filename = "mwclient/client.py" filename = "mwclient/client.py"
search = "__version__ = '{current_version}'"
replace = "__version__ = '{new_version}'"
[[tool.bumpversion.files]] [[tool.bumpversion.files]]
filename = "README.md" filename = "README.md"
[tool.mypy]
packages = ["mwclient", "test"]
strict = true
warn_unreachable = true
[[tool.mypy.overrides]]
module = "test.*"
disallow_untyped_calls = false
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = "requests_oauthlib"
ignore_missing_imports = true
#!/usr/bin/env python
import os
import sys
from setuptools import setup
here = os.path.abspath(os.path.dirname(__file__))
README = open(os.path.join(here, 'README.md')).read()
needs_pytest = set(['pytest', 'test', 'ptr']).intersection(sys.argv)
pytest_runner = ['pytest-runner'] if needs_pytest else []
setup(name='mwclient',
# See https://mwclient.readthedocs.io/en/latest/development/#making-a-release
# for how to update this field and release a new version.
version='0.10.1',
description='MediaWiki API client',
long_description=README,
long_description_content_type='text/markdown',
classifiers=[
'Programming Language :: Python',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
],
keywords='mediawiki wikipedia',
author='Bryan Tong Minh',
author_email='bryan.tongminh@gmail.com',
url='https://github.com/mwclient/mwclient',
license='MIT',
packages=['mwclient'],
install_requires=['requests-oauthlib'],
setup_requires=pytest_runner,
tests_require=['pytest', 'pytest-cov',
'responses>=0.3.0', 'responses!=0.6.0', 'setuptools'],
zip_safe=True
)
import http.client
import shutil
import subprocess
import time
from typing import Optional
from urllib.error import URLError, HTTPError
from urllib.request import urlopen
import pytest
import mwclient
if shutil.which("podman"):
_CONTAINER_RUNTIME = "podman"
elif shutil.which("docker"):
_CONTAINER_RUNTIME = "docker"
else:
raise RuntimeError("Neither podman nor docker is installed")
@pytest.fixture(
scope="class",
params=[("latest", "5002"), ("legacy", "5003"), ("lts", "5004")],
ids=["latest", "legacy", "lts"]
)
def site(request):
"""
Run a mediawiki container for the duration of the class, yield
a Site instance for it, then clean it up on exit. This is
parametrized so we get three containers and run the tests three
times for three mediawiki releases. We use podman because it's
much easier to use rootless than docker.
"""
(tag, port) = request.param
container = f"mwclient-{tag}"
# create the container, using upstream's official image. see
# https://hub.docker.com/_/mediawiki
args = [_CONTAINER_RUNTIME, "run", "--name", container, "-p", f"{port}:80",
"-d", f"docker.io/library/mediawiki:{tag}"]
subprocess.run(args)
# configure the wiki far enough that we can use the API. if you
# use this interactively the CSS doesn't work, I don't know why,
# don't think it really matters
args = [_CONTAINER_RUNTIME, "exec", container, "runuser", "-u", "www-data", "--",
"php", "/var/www/html/maintenance/install.php", "--server",
f"http://localhost:{port}", "--dbtype", "sqlite", "--pass", "weakpassword",
"--dbpath", "/var/www/data", "mwclient-test-wiki", "root"]
subprocess.run(args)
# create a regular user
args = [_CONTAINER_RUNTIME, "exec", container, "runuser", "-u", "www-data", "--",
"php", "/var/www/html/maintenance/createAndPromote.php", "testuser",
"weakpassword"]
subprocess.run(args)
# create an admin user
args = [_CONTAINER_RUNTIME, "exec", container, "runuser", "-u", "www-data", "--",
"php", "/var/www/html/maintenance/createAndPromote.php", "sysop",
"weakpassword", "--bureaucrat", "--sysop", "--interface-admin"]
subprocess.run(args)
# create a bot user
args = [_CONTAINER_RUNTIME, "exec", container, "runuser", "-u", "www-data", "--",
"php", "/var/www/html/maintenance/createAndPromote.php", "testbot",
"weakpassword", "--bot"]
subprocess.run(args)
# disable anonymous editing (we can't use redirection via podman
# exec for some reason, so we use sed)
args = [_CONTAINER_RUNTIME, "exec", container, "runuser", "-u", "www-data", "--",
"sed", "-i", r"$ a\$wgGroupPermissions['*']['edit'] = false;",
"/var/www/html/LocalSettings.php"]
subprocess.run(args)
# allow editing by users
args = [_CONTAINER_RUNTIME, "exec", container, "runuser", "-u", "www-data", "--",
"sed", "-i", r"$ a\$wgGroupPermissions['user']['edit'] = true;",
"/var/www/html/LocalSettings.php"]
subprocess.run(args)
# block until the server is actually running, up to 30 seconds
start = int(time.time())
resp: Optional[http.client.HTTPResponse] = None
while not resp:
try:
resp = urlopen(f"http://localhost:{port}")
except (ValueError, URLError, HTTPError) as err:
if int(time.time()) - start > 30:
print("Waited more than 30 seconds for server to start!")
raise err
else:
time.sleep(0.1)
# set up mwclient.site instance and yield it
yield mwclient.Site(f"localhost:{port}", path="/", scheme="http", force_login=False)
# -t=0 just hard stops it immediately, saves time
args = [_CONTAINER_RUNTIME, "stop", "-t=0", container]
subprocess.run(args)
args = [_CONTAINER_RUNTIME, "rm", container]
subprocess.run(args)
class TestAnonymous:
def test_page_load(self, site):
"""Test we can read a page from the sites."""
pg = site.pages["Main_Page"]
text = pg.text()
assert text.startswith("<strong>MediaWiki has been installed")
def test_page_create(self, site):
"""Test we get expected error if we try to create a page."""
pg = site.pages["Anonymous New Page"]
with pytest.raises(mwclient.errors.ProtectedPageError):
pg.edit("Hi I'm a new page", "create new page")
def test_expandtemplates(self, site):
"""Test we can expand templates."""
wikitext = site.expandtemplates("{{CURRENTYEAR}}")
assert wikitext == str(time.localtime().tm_year)
def test_expandtemplates_with_parsetree(self, site):
"""Test we can expand templates and get the parse tree."""
wikitext, parsetree = site.expandtemplates("{{CURRENTYEAR}}", generatexml=True)
assert wikitext == str(time.localtime().tm_year)
assert parsetree == "<root><template><title>CURRENTYEAR</title></template></root>"
class TestLogin:
def test_login_wrong_password(self, site):
"""Test we raise correct error for login() with wrong password."""
assert not site.logged_in
with pytest.raises(mwclient.errors.LoginError):
site.login(username="testuser", password="thisiswrong")
assert not site.logged_in
def test_login(self, site):
"""
Test we can log in to the sites with login() and do authed
stuff.
"""
site.login(username="testuser", password="weakpassword")
assert site.logged_in
# test we can create a page
pg = site.pages["Authed New Page"]
pg.edit("Hi I'm a new page", "create new page")
# we have to reinit because of Page.exists
# https://github.com/mwclient/mwclient/issues/354
pg = site.pages["Authed New Page"]
assert pg.text() == "Hi I'm a new page"
# test we can move it
ret = pg.move("Authed Moved Page")
pg = site.pages["Authed Moved Page"]
assert pg.text() == "Hi I'm a new page"
def test_page_delete(self, site):
"""Test we can login, create and delete a page as sysop."""
site.login(username="sysop", password="weakpassword")
pg = site.pages["Sysop New Page"]
pg.edit("Hi I'm a new page", "create new page")
pg = site.pages["Sysop New Page"]
assert pg.text() == "Hi I'm a new page"
assert pg.exists == True
pg.delete()
pg = site.pages["Sysop New Page"]
assert pg.text() == ""
assert pg.exists == False
class TestClientLogin:
def test_clientlogin_wrong_password(self, site):
"""Test we raise correct error for clientlogin() with wrong password."""
with pytest.raises(mwclient.errors.LoginError):
site.clientlogin(username="testuser", password="thisiswrong")
assert not site.logged_in
def test_clientlogin(self, site):
"""
Test we can log in to the site with clientlogin() and
create a page.
"""
site.clientlogin(username="testuser", password="weakpassword")
assert site.logged_in
pg = site.pages["Anonymous New Page"]
pg.edit("Hi I'm a new page", "create new page")
pg = site.pages["Anonymous New Page"]
assert pg.text() == "Hi I'm a new page"
from copy import deepcopy
from datetime import date
import json import json
from io import BytesIO
import logging import logging
import sys import sys
import time import time
import unittest import unittest
import unittest.mock as mock import unittest.mock as mock
from copy import deepcopy
from datetime import date
from io import StringIO
import mwclient
import pkg_resources # part of setuptools
import pytest import pytest
import requests import requests
import responses import responses
from requests_oauthlib import OAuth1 from requests_oauthlib import OAuth1
import mwclient
if __name__ == "__main__": if __name__ == "__main__":
print() print()
print("Note: Running in stand-alone mode. Consult the README") print("Note: Running in stand-alone mode. Consult the README")
...@@ -26,24 +26,61 @@ logging.basicConfig(level=logging.DEBUG) ...@@ -26,24 +26,61 @@ logging.basicConfig(level=logging.DEBUG)
class TestCase(unittest.TestCase): class TestCase(unittest.TestCase):
def metaResponse(self, **kwargs): def metaResponse(self, version='1.24wmf17', rights=None):
tpl = '{"query":{"general":{"generator":"MediaWiki %(version)s"},"namespaces":{"-1":{"*":"Special","canonical":"Special","case":"first-letter","id":-1},"-2":{"*":"Media","canonical":"Media","case":"first-letter","id":-2},"0":{"*":"","case":"first-letter","content":"","id":0},"1":{"*":"Talk","canonical":"Talk","case":"first-letter","id":1,"subpages":""},"10":{"*":"Template","canonical":"Template","case":"first-letter","id":10,"subpages":""},"100":{"*":"Test namespace 1","canonical":"Test namespace 1","case":"first-letter","id":100,"subpages":""},"101":{"*":"Test namespace 1 talk","canonical":"Test namespace 1 talk","case":"first-letter","id":101,"subpages":""},"102":{"*":"Test namespace 2","canonical":"Test namespace 2","case":"first-letter","id":102,"subpages":""},"103":{"*":"Test namespace 2 talk","canonical":"Test namespace 2 talk","case":"first-letter","id":103,"subpages":""},"11":{"*":"Template talk","canonical":"Template talk","case":"first-letter","id":11,"subpages":""},"1198":{"*":"Translations","canonical":"Translations","case":"first-letter","id":1198,"subpages":""},"1199":{"*":"Translations talk","canonical":"Translations talk","case":"first-letter","id":1199,"subpages":""},"12":{"*":"Help","canonical":"Help","case":"first-letter","id":12,"subpages":""},"13":{"*":"Help talk","canonical":"Help talk","case":"first-letter","id":13,"subpages":""},"14":{"*":"Category","canonical":"Category","case":"first-letter","id":14},"15":{"*":"Category talk","canonical":"Category talk","case":"first-letter","id":15,"subpages":""},"2":{"*":"User","canonical":"User","case":"first-letter","id":2,"subpages":""},"2500":{"*":"VisualEditor","canonical":"VisualEditor","case":"first-letter","id":2500},"2501":{"*":"VisualEditor talk","canonical":"VisualEditor talk","case":"first-letter","id":2501},"2600":{"*":"Topic","canonical":"Topic","case":"first-letter","defaultcontentmodel":"flow-board","id":2600},"3":{"*":"User talk","canonical":"User talk","case":"first-letter","id":3,"subpages":""},"4":{"*":"Wikipedia","canonical":"Project","case":"first-letter","id":4,"subpages":""},"460":{"*":"Campaign","canonical":"Campaign","case":"case-sensitive","defaultcontentmodel":"Campaign","id":460},"461":{"*":"Campaign talk","canonical":"Campaign talk","case":"case-sensitive","id":461},"5":{"*":"Wikipedia talk","canonical":"Project talk","case":"first-letter","id":5,"subpages":""},"6":{"*":"File","canonical":"File","case":"first-letter","id":6},"7":{"*":"File talk","canonical":"File talk","case":"first-letter","id":7,"subpages":""},"710":{"*":"TimedText","canonical":"TimedText","case":"first-letter","id":710},"711":{"*":"TimedText talk","canonical":"TimedText talk","case":"first-letter","id":711},"8":{"*":"MediaWiki","canonical":"MediaWiki","case":"first-letter","id":8,"subpages":""},"828":{"*":"Module","canonical":"Module","case":"first-letter","id":828,"subpages":""},"829":{"*":"Module talk","canonical":"Module talk","case":"first-letter","id":829,"subpages":""},"866":{"*":"CNBanner","canonical":"CNBanner","case":"first-letter","id":866},"867":{"*":"CNBanner talk","canonical":"CNBanner talk","case":"first-letter","id":867,"subpages":""},"9":{"*":"MediaWiki talk","canonical":"MediaWiki talk","case":"first-letter","id":9,"subpages":""},"90":{"*":"Thread","canonical":"Thread","case":"first-letter","id":90},"91":{"*":"Thread talk","canonical":"Thread talk","case":"first-letter","id":91},"92":{"*":"Summary","canonical":"Summary","case":"first-letter","id":92},"93":{"*":"Summary talk","canonical":"Summary talk","case":"first-letter","id":93}},"userinfo":{"anon":"","groups":["*"],"id":0,"name":"127.0.0.1","rights": %(rights)s}}}' if rights is None:
tpl = tpl % {'version': kwargs.get('version', '1.24wmf17'), rights = [
'rights': json.dumps(kwargs.get('rights', ["createaccount", "read", "edit", "createpage", "createtalk", "writeapi", "editmyusercss", "editmyuserjs", "viewmywatchlist", "editmywatchlist", "viewmyprivateinfo", "editmyprivateinfo", "editmyoptions", "centralauth-merge", "abusefilter-view", "abusefilter-log", "translate", "vipsscaler-test", "upload"])) "createaccount", "read", "edit", "createpage", "createtalk",
} "editmyusercss", "editmyuserjs", "viewmywatchlist",
"editmywatchlist", "viewmyprivateinfo", "editmyprivateinfo",
res = json.loads(tpl) "editmyoptions", "centralauth-merge", "abusefilter-view",
if kwargs.get('writeapi', True): "abusefilter-log", "translate", "vipsscaler-test", "upload"
res['query']['general']['writeapi'] = '' ]
# @formatter:off
namespaces = {
-2: {"id": -2, "*": "Media", "canonical": "Media", "case": "first-letter"},
-1: {"id": -1, "*": "Special", "canonical": "Special", "case": "first-letter"},
0: {"id": 0, "*": "", "case": "first-letter", "content": ""},
1: {"id": 1, "*": "Talk", "canonical": "Talk", "case": "first-letter", "subpages": ""},
2: {"id": 2, "*": "User", "canonical": "User", "case": "first-letter", "subpages": ""},
3: {"id": 3, "*": "User talk", "canonical": "User talk", "case": "first-letter", "subpages": ""},
4: {"id": 4, "*": "Wikipedia", "canonical": "Project", "case": "first-letter", "subpages": ""},
5: {"id": 5, "*": "Wikipedia talk", "canonical": "Project talk", "case": "first-letter", "subpages": ""},
6: {"id": 6, "*": "File", "canonical": "File", "case": "first-letter"},
7: {"id": 7, "*": "File talk", "canonical": "File talk", "case": "first-letter", "subpages": ""},
8: {"id": 8, "*": "MediaWiki", "canonical": "MediaWiki", "case": "first-letter", "subpages": ""},
9: {"id": 9, "*": "MediaWiki talk", "canonical": "MediaWiki talk", "case": "first-letter", "subpages": ""},
10: {"id": 10, "*": "Template", "canonical": "Template", "case": "first-letter", "subpages": ""},
11: {"id": 11, "*": "Template talk", "canonical": "Template talk", "case": "first-letter", "subpages": ""},
12: {"id": 12, "*": "Help", "canonical": "Help", "case": "first-letter", "subpages": ""},
13: {"id": 13, "*": "Help talk", "canonical": "Help talk", "case": "first-letter", "subpages": ""},
14: {"id": 14, "*": "Category", "canonical": "Category", "case": "first-letter"},
15: {"id": 15, "*": "Category talk", "canonical": "Category talk", "case": "first-letter", "subpages": ""},
}
# @formatter:on
return res return {
"query": {
"general": {
"generator": f"MediaWiki {version}"
},
"namespaces": namespaces,
"userinfo": {
"anon": "",
"groups": ["*"],
"id": 0,
"name": "127.0.0.1",
"rights": rights
}
}
}
def metaResponseAsJson(self, **kwargs): def metaResponseAsJson(self, **kwargs):
return json.dumps(self.metaResponse(**kwargs)) return json.dumps(self.metaResponse(**kwargs))
def httpShouldReturn(self, body=None, callback=None, scheme='https', host='test.wikipedia.org', path='/w/', def httpShouldReturn(self, body=None, callback=None, scheme='https', host='test.wikipedia.org', path='/w/',
script='api', headers=None, status=200, method='GET'): script='api', headers=None, status=200, method='GET'):
url = '{scheme}://{host}{path}{script}.php'.format(scheme=scheme, host=host, path=path, script=script) url = f'{scheme}://{host}{path}{script}.php'
mock = responses.GET if method == 'GET' else responses.POST mock = responses.GET if method == 'GET' else responses.POST
if body is None: if body is None:
responses.add_callback(mock, url, callback=callback) responses.add_callback(mock, url, callback=callback)
...@@ -90,7 +127,15 @@ class TestClient(TestCase): ...@@ -90,7 +127,15 @@ class TestClient(TestCase):
def testVersion(self): def testVersion(self):
# The version specified in setup.py should equal the one specified in client.py # The version specified in setup.py should equal the one specified in client.py
version = pkg_resources.require("mwclient")[0].version
if sys.version_info >= (3, 8):
import importlib.metadata
version = importlib.metadata.version("mwclient")
else:
import pkg_resources # part of setuptools
version = pkg_resources.require("mwclient")[0].version
assert version == mwclient.__version__ assert version == mwclient.__version__
...@@ -120,8 +165,8 @@ class TestClient(TestCase): ...@@ -120,8 +165,8 @@ class TestClient(TestCase):
site = mwclient.Site('test.wikipedia.org') site = mwclient.Site('test.wikipedia.org')
assert len(responses.calls) == 2 assert len(responses.calls) == 2
assert 'retry-after' in responses.calls[0].response.headers assert 'retry-after' in responses.calls[0].response.headers # type: ignore
assert 'retry-after' not in responses.calls[1].response.headers assert 'retry-after' not in responses.calls[1].response.headers # type: ignore
@responses.activate @responses.activate
def test_http_error(self): def test_http_error(self):
...@@ -169,6 +214,7 @@ class TestClient(TestCase): ...@@ -169,6 +214,7 @@ class TestClient(TestCase):
site = mwclient.Site('test.wikipedia.org') site = mwclient.Site('test.wikipedia.org')
assert responses.calls[0].request.url is not None
assert 'action=query' in responses.calls[0].request.url assert 'action=query' in responses.calls[0].request.url
assert 'meta=siteinfo%7Cuserinfo' in responses.calls[0].request.url assert 'meta=siteinfo%7Cuserinfo' in responses.calls[0].request.url
...@@ -196,7 +242,8 @@ class TestClient(TestCase): ...@@ -196,7 +242,8 @@ class TestClient(TestCase):
self.httpShouldReturn(self.metaResponseAsJson()) self.httpShouldReturn(self.metaResponseAsJson())
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
site = mwclient.Site('test.wikipedia.org', httpauth=1) site = mwclient.Site('test.wikipedia.org',
httpauth=1) # type: ignore[arg-type]
@responses.activate @responses.activate
def test_oauth(self): def test_oauth(self):
...@@ -353,9 +400,9 @@ class TestClient(TestCase): ...@@ -353,9 +400,9 @@ class TestClient(TestCase):
} }
}), method='GET') }), method='GET')
answers = set(result['fulltext'] for result in site.ask('test')) answers = {result['fulltext'] for result in site.ask('test')}
assert answers == set(('Serendipitet', 'Indeks (bibliotekfag)')) assert answers == {'Serendipitet', 'Indeks (bibliotekfag)'}
@responses.activate @responses.activate
def test_smw_response_v2(self): def test_smw_response_v2(self):
...@@ -395,9 +442,9 @@ class TestClient(TestCase): ...@@ -395,9 +442,9 @@ class TestClient(TestCase):
} }
}), method='GET') }), method='GET')
answers = set(result['fulltext'] for result in site.ask('test')) answers = {result['fulltext'] for result in site.ask('test')}
assert answers == set(('Serendipitet', 'Indeks (bibliotekfag)')) assert answers == {'Serendipitet', 'Indeks (bibliotekfag)'}
@responses.activate @responses.activate
def test_repr(self): def test_repr(self):
...@@ -469,6 +516,18 @@ class TestClient(TestCase): ...@@ -469,6 +516,18 @@ class TestClient(TestCase):
site.raw_api("query", "GET", retry_on_error=False) site.raw_api("query", "GET", retry_on_error=False)
assert timesleep.call_count == 25 assert timesleep.call_count == 25
@responses.activate
def test_connection_options(self):
self.httpShouldReturn(self.metaResponseAsJson())
args = {"timeout": 60, "stream": False}
site = mwclient.Site('test.wikipedia.org', connection_options=args)
assert site.requests == args
with pytest.warns(DeprecationWarning):
site = mwclient.Site('test.wikipedia.org', reqs=args)
assert site.requests == args
with pytest.raises(ValueError):
site = mwclient.Site('test.wikipedia.org', reqs=args, connection_options=args)
class TestLogin(TestCase): class TestLogin(TestCase):
@mock.patch('mwclient.client.Site.site_init') @mock.patch('mwclient.client.Site.site_init')
...@@ -553,7 +612,7 @@ class TestLogin(TestCase): ...@@ -553,7 +612,7 @@ class TestLogin(TestCase):
# this would be done by site_init usually, but we're mocking it # this would be done by site_init usually, but we're mocking it
site.version = (1, 28, 0) site.version = (1, 28, 0)
success = site.clientlogin(username='myusername', password='mypassword') success = site.clientlogin(username='myusername', password='mypassword')
url = '%s://%s' % (site.scheme, site.host) url = f'{site.scheme}://{site.host}'
call_args = raw_api.call_args_list call_args = raw_api.call_args_list
...@@ -602,7 +661,7 @@ class TestLogin(TestCase): ...@@ -602,7 +661,7 @@ class TestLogin(TestCase):
'clientlogin', 'POST', 'clientlogin', 'POST',
username='myusername', username='myusername',
password='mypassword', password='mypassword',
loginreturnurl='%s://%s' % (site.scheme, site.host), loginreturnurl=f'{site.scheme}://{site.host}',
logintoken=login_token logintoken=login_token
) )
...@@ -629,7 +688,7 @@ class TestLogin(TestCase): ...@@ -629,7 +688,7 @@ class TestLogin(TestCase):
# this would be done by site_init usually, but we're mocking it # this would be done by site_init usually, but we're mocking it
site.version = (1, 28, 0) site.version = (1, 28, 0)
success = site.clientlogin(username='myusername', password='mypassword') success = site.clientlogin(username='myusername', password='mypassword')
url = '%s://%s' % (site.scheme, site.host) url = f'{site.scheme}://{site.host}'
call_args = raw_api.call_args_list call_args = raw_api.call_args_list
...@@ -684,6 +743,47 @@ class TestClientApiMethods(TestCase): ...@@ -684,6 +743,47 @@ class TestClientApiMethods(TestCase):
assert revisions[1]['revid'] == 689816909 assert revisions[1]['revid'] == 689816909
class TestVersionTupleFromGenerator:
@pytest.mark.parametrize('version, expected', [
('MediaWiki 1.24', (1, 24)),
('MediaWiki 1.24.0', (1, 24, 0)),
('MediaWiki 1.24.0-wmf.1', (1, 24, 0, 'wmf', 1)),
('MediaWiki 1.24.1alpha', (1, 24, 1, 'alpha')),
('MediaWiki 1.24.1alpha1', (1, 24, 1, 'alpha', 1)),
('MediaWiki 1.24.1-rc.3', (1, 24, 1, 'rc', 3)),
])
def test_version_tuple_from_generator(self, version, expected):
assert mwclient.Site.version_tuple_from_generator(version) == expected
def test_version_tuple_from_generator_empty(self):
with pytest.raises(mwclient.errors.MediaWikiVersionError):
mwclient.Site.version_tuple_from_generator('')
def test_version_tuple_from_generator_invalid_prefix(self):
with pytest.raises(mwclient.errors.MediaWikiVersionError):
mwclient.Site.version_tuple_from_generator('Foo 1.24.1')
def test_version_tuple_from_generator_no_minor(self):
with pytest.raises(mwclient.errors.MediaWikiVersionError):
mwclient.Site.version_tuple_from_generator('MediaWiki 1')
def test_version_tuple_from_generator_major_is_not_number(self):
with pytest.raises(mwclient.errors.MediaWikiVersionError):
mwclient.Site.version_tuple_from_generator('MediaWiki foo.24.1')
def test_version_tuple_from_generator_minor_is_not_number(self):
with pytest.raises(mwclient.errors.MediaWikiVersionError):
mwclient.Site.version_tuple_from_generator('MediaWiki 1.foo.1')
def test_version_tuple_from_generator_major_and_minor_are_not_numbers(self):
with pytest.raises(mwclient.errors.MediaWikiVersionError):
mwclient.Site.version_tuple_from_generator('MediaWiki foo.bar.1')
def test_version_tuple_from_generator_patch_is_not_number(self):
assert mwclient.Site.version_tuple_from_generator('MediaWiki 1.24.foo') == (1, 24, 'foo')
class TestClientUploadArgs(TestCase): class TestClientUploadArgs(TestCase):
def setUp(self): def setUp(self):
...@@ -734,7 +834,7 @@ class TestClientUploadArgs(TestCase): ...@@ -734,7 +834,7 @@ class TestClientUploadArgs(TestCase):
# Test that methods are called, and arguments sent as expected # Test that methods are called, and arguments sent as expected
self.configure() self.configure()
self.site.upload(file=StringIO('test'), filename=self.vars['fname'], comment=self.vars['comment']) self.site.upload(file=BytesIO(b'test'), filename=self.vars['fname'], comment=self.vars['comment'])
args, kwargs = self.raw_call.call_args args, kwargs = self.raw_call.call_args
data = args[1] data = args[1]
...@@ -750,19 +850,46 @@ class TestClientUploadArgs(TestCase): ...@@ -750,19 +850,46 @@ class TestClientUploadArgs(TestCase):
self.configure() self.configure()
with pytest.raises(TypeError): with pytest.raises(TypeError):
self.site.upload(file=StringIO('test')) self.site.upload(file=BytesIO(b'test'))
def test_upload_ambigitious_args(self): def test_upload_ambigitious_args(self):
self.configure() self.configure()
with pytest.raises(TypeError): with pytest.raises(TypeError):
self.site.upload(filename='Test', file=StringIO('test'), filekey='abc') self.site.upload(filename='Test', file=BytesIO(b'test'), filekey='abc')
def test_upload_missing_upload_permission(self): def test_upload_missing_upload_permission(self):
self.configure(rights=['read']) self.configure(rights=['read'])
with pytest.raises(mwclient.errors.InsufficientPermission): with pytest.raises(mwclient.errors.InsufficientPermission):
self.site.upload(filename='Test', file=StringIO('test')) self.site.upload(filename='Test', file=BytesIO(b'test'))
def test_upload_file_exists(self):
self.configure()
self.raw_call.side_effect = [
self.makePageResponse(title='File:Test.jpg', imagerepository='local',
imageinfo=[{
"comment": "",
"height": 1440,
"metadata": [],
"sha1": "69a764a9cf8307ea4130831a0aa0b9b7f9585726",
"size": 123,
"timestamp": "2013-12-22T07:11:07Z",
"user": "TestUser",
"width": 2160
}]),
json.dumps({'query': {'tokens': {'csrftoken': self.vars['token']}}}),
json.dumps({
'upload': {'result': 'Warning',
'warnings': {'duplicate': ['Test.jpg'],
'exists': 'Test.jpg'},
'filekey': '1apyzwruya84.da2cdk.1.jpg',
'sessionkey': '1apyzwruya84.da2cdk.1.jpg'}
})
]
with pytest.raises(mwclient.errors.FileExists):
self.site.upload(file=BytesIO(b'test'), filename='Test.jpg', ignore=False)
class TestClientGetTokens(TestCase): class TestClientGetTokens(TestCase):
...@@ -1420,6 +1547,74 @@ class TestUser(TestCase): ...@@ -1420,6 +1547,74 @@ class TestUser(TestCase):
assert real_call_kwargs == mock_call_kwargs assert real_call_kwargs == mock_call_kwargs
assert mock_call.args == call_args[2].args assert mock_call.args == call_args[2].args
class TestClientExpandtemplates(TestCase):
def setUp(self):
self.raw_call = mock.patch('mwclient.client.Site.raw_call').start()
def configure(self, version='1.24'):
self.raw_call.return_value = self.metaResponseAsJson(version=version)
self.site = mwclient.Site('test.wikipedia.org')
def tearDown(self):
mock.patch.stopall()
def test_expandtemplates_1_13(self):
self.configure('1.16')
self.raw_call.return_value = json.dumps({
'expandtemplates': {
'*': '2024'
}
})
wikitext = self.site.expandtemplates('{{CURRENTYEAR}}')
assert wikitext == '2024'
def test_expandtemplates_1_13_generatexml(self):
self.configure('1.16')
self.raw_call.return_value = json.dumps({
'parsetree': {
'*': '<root><template><title>CURRENTYEAR</title></template></root>'
},
'expandtemplates': {
'*': '2024'
}
})
expanded = self.site.expandtemplates('{{CURRENTYEAR}}', generatexml=True)
assert isinstance(expanded, tuple)
assert expanded[0] == '2024'
assert expanded[1] == '<root><template><title>CURRENTYEAR</title></template></root>'
def test_expandtemplates_1_24(self):
self.configure('1.24')
self.raw_call.return_value = json.dumps({
'expandtemplates': {
'wikitext': '2024'
}
})
wikitext = self.site.expandtemplates('{{CURRENTYEAR}}')
assert wikitext == '2024'
def test_expandtemplates_1_24_generatexml(self):
self.configure('1.24')
self.raw_call.return_value = json.dumps({
'expandtemplates': {
'parsetree': '<root><template><title>CURRENTYEAR</title></template></root>',
'wikitext': '2024'
}
})
expanded = self.site.expandtemplates('{{CURRENTYEAR}}', generatexml=True)
assert isinstance(expanded, tuple)
assert expanded[0] == '2024'
assert expanded[1] == '<root><template><title>CURRENTYEAR</title></template></root>'
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
import unittest import unittest
import pytest import pytest
import logging
import requests
import responses
import json
import mwclient import mwclient
from mwclient.listing import List, GeneratorList from mwclient.listing import List, NestedList, GeneratorList
from mwclient.listing import Category, PageList, RevisionsIterator
from mwclient.page import Page
import unittest.mock as mock import unittest.mock as mock
...@@ -22,7 +20,54 @@ class TestList(unittest.TestCase): ...@@ -22,7 +20,54 @@ class TestList(unittest.TestCase):
def setUp(self): def setUp(self):
pass pass
def setupDummyResponses(self, mock_site, result_member, ns=None): def setupDummyResponsesOne(self, mock_site, result_member, ns=None):
if ns is None:
ns = [0, 0, 0]
mock_site.get.side_effect = [
{
'continue': {
'apcontinue': 'Kre_Mbaye',
'continue': '-||'
},
'query': {
result_member: [
{
"pageid": 19839654,
"ns": ns[0],
"title": "Kre'fey",
},
]
}
},
{
'continue': {
'apcontinue': 'Kre_Blip',
'continue': '-||'
},
'query': {
result_member: [
{
"pageid": 19839654,
"ns": ns[1],
"title": "Kre-O",
}
]
}
},
{
'query': {
result_member: [
{
"pageid": 30955295,
"ns": ns[2],
"title": "Kre-O Transformers",
}
]
}
},
]
def setupDummyResponsesTwo(self, mock_site, result_member, ns=None):
if ns is None: if ns is None:
ns = [0, 0, 0] ns = [0, 0, 0]
mock_site.get.side_effect = [ mock_site.get.side_effect = [
...@@ -64,19 +109,65 @@ class TestList(unittest.TestCase): ...@@ -64,19 +109,65 @@ class TestList(unittest.TestCase):
# Test that the list fetches all three responses # Test that the list fetches all three responses
# and yields dicts when return_values not set # and yields dicts when return_values not set
lst = List(mock_site, 'allpages', 'ap', limit=2) lst = List(mock_site, 'allpages', 'ap', api_chunk_size=2)
self.setupDummyResponses(mock_site, 'allpages') self.setupDummyResponsesTwo(mock_site, 'allpages')
vals = [x for x in lst]
assert len(vals) == 3
assert type(vals[0]) == dict
assert lst.args["aplimit"] == "2"
assert mock_site.get.call_count == 2
@mock.patch('mwclient.client.Site')
def test_list_limit_deprecated(self, mock_site):
# Test that the limit arg acts as api_chunk_size but generates
# DeprecationWarning
with pytest.deprecated_call():
lst = List(mock_site, 'allpages', 'ap', limit=2)
self.setupDummyResponsesTwo(mock_site, 'allpages')
vals = [x for x in lst] vals = [x for x in lst]
assert len(vals) == 3 assert len(vals) == 3
assert type(vals[0]) == dict assert type(vals[0]) == dict
assert lst.args["aplimit"] == "2"
assert mock_site.get.call_count == 2
@mock.patch('mwclient.client.Site')
def test_list_max_items(self, mock_site):
# Test that max_items properly caps the list
# iterations
mock_site.api_limit = 500
lst = List(mock_site, 'allpages', 'ap', max_items=2)
self.setupDummyResponsesTwo(mock_site, 'allpages')
vals = [x for x in lst]
assert len(vals) == 2
assert type(vals[0]) == dict
assert lst.args["aplimit"] == "2"
assert mock_site.get.call_count == 1
@mock.patch('mwclient.client.Site')
def test_list_max_items_continuation(self, mock_site):
# Test that max_items and api_chunk_size work together
mock_site.api_limit = 500
lst = List(mock_site, 'allpages', 'ap', max_items=2, api_chunk_size=1)
self.setupDummyResponsesOne(mock_site, 'allpages')
vals = [x for x in lst]
assert len(vals) == 2
assert type(vals[0]) == dict
assert lst.args["aplimit"] == "1"
assert mock_site.get.call_count == 2
@mock.patch('mwclient.client.Site') @mock.patch('mwclient.client.Site')
def test_list_with_str_return_value(self, mock_site): def test_list_with_str_return_value(self, mock_site):
# Test that the List yields strings when return_values is string # Test that the List yields strings when return_values is string
lst = List(mock_site, 'allpages', 'ap', limit=2, return_values='title') lst = List(mock_site, 'allpages', 'ap', limit=2, return_values='title')
self.setupDummyResponses(mock_site, 'allpages') self.setupDummyResponsesTwo(mock_site, 'allpages')
vals = [x for x in lst] vals = [x for x in lst]
assert len(vals) == 3 assert len(vals) == 3
...@@ -88,18 +179,116 @@ class TestList(unittest.TestCase): ...@@ -88,18 +179,116 @@ class TestList(unittest.TestCase):
lst = List(mock_site, 'allpages', 'ap', limit=2, lst = List(mock_site, 'allpages', 'ap', limit=2,
return_values=('title', 'ns')) return_values=('title', 'ns'))
self.setupDummyResponses(mock_site, 'allpages') self.setupDummyResponsesTwo(mock_site, 'allpages')
vals = [x for x in lst] vals = [x for x in lst]
assert len(vals) == 3 assert len(vals) == 3
assert type(vals[0]) == tuple assert type(vals[0]) == tuple
@mock.patch('mwclient.client.Site')
def test_list_empty(self, mock_site):
# Test that we handle an empty response from get correctly
# (stop iterating)
lst = List(mock_site, 'allpages', 'ap', limit=2,
return_values=('title', 'ns'))
mock_site.get.side_effect = [{}]
vals = [x for x in lst]
assert len(vals) == 0
@mock.patch('mwclient.client.Site')
def test_list_invalid(self, mock_site):
# Test that we handle the response for a list that doesn't
# exist correctly (set an empty iterator, then stop
# iterating)
mock_site.api_limit = 500
lst = List(mock_site, 'allpagess', 'ap')
mock_site.get.side_effect = [
{
'batchcomplete': '',
'warnings': {
'main': {'*': 'Unrecognized parameter: aplimit.'},
'query': {'*': 'Unrecognized value for parameter "list": allpagess'}
},
'query': {
'userinfo': {
'id': 0,
'name': 'DEAD:BEEF:CAFE',
'anon': ''
}
}
}
]
vals = [x for x in lst]
assert len(vals) == 0
@mock.patch('mwclient.client.Site')
def test_list_repr(self, mock_site):
# Test __repr__ of a List is as expected
mock_site.__str__.return_value = "some wiki"
lst = List(mock_site, 'allpages', 'ap', limit=2,
return_values=('title', 'ns'))
assert repr(lst) == "<List object 'allpages' for some wiki>"
@mock.patch('mwclient.client.Site')
def test_get_list(self, mock_site):
# Test get_list behaves as expected
lst = List.get_list()(mock_site, 'allpages', 'ap', limit=2,
return_values=('title', 'ns'))
genlst = List.get_list(True)(mock_site, 'allpages', 'ap', limit=2,
return_values=('title', 'ns'))
assert isinstance(lst, List)
assert not isinstance(lst, GeneratorList)
assert isinstance(genlst, GeneratorList)
@mock.patch('mwclient.client.Site')
def test_nested_list(self, mock_site):
# Test NestedList class works as expected
mock_site.api_limit = 500
nested = NestedList('entries', mock_site, 'checkuserlog', 'cul')
mock_site.get.side_effect = [
# this is made-up because I do not have permissions on any
# wiki with this extension installed and the extension doc
# does not show a sample API response
{
'query': {
'checkuserlog': {
'entries': [
{
'user': 'Dreamyjazz',
'action': 'users',
'ip': '172.18.0.1',
'message': 'suspected sockpuppet',
'time': 1662328680
},
{
'user': 'Dreamyjazz',
'action': 'ip',
'targetuser': 'JohnDoe124',
'message': 'suspected sockpuppet',
'time': 1662328380
},
]
}
}
}
]
vals = [x for x in nested]
assert len(vals) == 2
assert vals[0]['action'] == 'users'
assert vals[1]['action'] == 'ip'
@mock.patch('mwclient.client.Site') @mock.patch('mwclient.client.Site')
def test_generator_list(self, mock_site): def test_generator_list(self, mock_site):
# Test that the GeneratorList yields Page objects # Test that the GeneratorList yields Page objects
mock_site.api_limit = 500
lst = GeneratorList(mock_site, 'pages', 'p') lst = GeneratorList(mock_site, 'pages', 'p')
self.setupDummyResponses(mock_site, 'pages', ns=[0, 6, 14]) self.setupDummyResponsesTwo(mock_site, 'pages', ns=[0, 6, 14])
vals = [x for x in lst] vals = [x for x in lst]
assert len(vals) == 3 assert len(vals) == 3
...@@ -107,5 +296,172 @@ class TestList(unittest.TestCase): ...@@ -107,5 +296,172 @@ class TestList(unittest.TestCase):
assert type(vals[1]) == mwclient.image.Image assert type(vals[1]) == mwclient.image.Image
assert type(vals[2]) == mwclient.listing.Category assert type(vals[2]) == mwclient.listing.Category
@mock.patch('mwclient.client.Site')
def test_category(self, mock_site):
# Test that Category works as expected
mock_site.__str__.return_value = "some wiki"
mock_site.api_limit = 500
# first response is for Page.__init__ as Category inherits
# from both Page and GeneratorList, second response is for
# the Category treated as an iterator with the namespace
# filter applied, third response is for the Category.members()
# call without a namespace filter
mock_site.get.side_effect = [
{
'query': {
'pages': {
'54565': {
'pageid': 54565, 'ns': 14, 'title': 'Category:Furry things'
}
}
}
},
{
'query': {
'pages': {
'36245': {
'pageid': 36245,
'ns': 118,
'title': 'Draft:Cat'
},
'36275': {
'pageid': 36275,
'ns': 118,
'title': 'Draft:Dog'
}
}
}
},
{
'query': {
'pages': {
'36245': {
'pageid': 36245,
'ns': 118,
'title': 'Draft:Cat'
},
'36275': {
'pageid': 36275,
'ns': 118,
'title': 'Draft:Dog'
},
'36295': {
'pageid': 36295,
'ns': 0,
'title': 'Hamster'
}
}
}
},
]
cat = Category(mock_site, 'Category:Furry things', namespace=118)
assert repr(cat) == "<Category object 'Category:Furry things' for some wiki>"
assert cat.args['gcmnamespace'] == 118
vals = [x for x in cat]
assert len(vals) == 2
assert vals[0].name == "Draft:Cat"
newcat = cat.members()
assert 'gcmnamespace' not in newcat.args
vals = [x for x in newcat]
assert len(vals) == 3
assert vals[2].name == "Hamster"
@mock.patch('mwclient.client.Site')
def test_pagelist(self, mock_site):
# Test that PageList works as expected
mock_site.__str__.return_value = "some wiki"
mock_site.api_limit = 500
mock_site.namespaces = {0: "", 6: "Image", 14: "Category"}
mock_site.get.return_value = {
'query': {
'pages': {
'8052484': {
'pageid': 8052484, 'ns': 0, 'title': 'Impossible'
}
}
}
}
pl = PageList(mock_site, start="Herring", end="Marmalade")
assert pl.args["gapfrom"] == "Herring"
assert pl.args["gapto"] == "Marmalade"
pg = pl["Impossible"]
assert isinstance(pg, Page)
assert mock_site.get.call_args[0] == ("query",)
assert mock_site.get.call_args[1]["titles"] == "Impossible"
# covers the catch of AttributeError in get()
pg = pl[8052484]
assert isinstance(pg, Page)
assert mock_site.get.call_args[0] == ("query",)
assert mock_site.get.call_args[1]["pageids"] == 8052484
pg = pl["Category:Spreads"]
assert mock_site.get.call_args[1]["titles"] == "Category:Spreads"
assert isinstance(pg, Category)
pl = PageList(mock_site, prefix="Ham")
assert pl.args["gapprefix"] == "Ham"
@mock.patch('mwclient.client.Site')
def test_revisions_iterator(self, mock_site):
# Test RevisionsIterator, including covering a line of
# PageProperty.set_iter
mock_site.api_limit = 500
mock_site.get.return_value = {
'query': {
'pages': {
'8052484': {
'pageid': 8052484,
'ns': 0,
'title': 'Impossible',
'revisions': [
{
"revid": 5000,
"parentid": 4999,
"user": "Bob",
"comment": "an edit"
},
{
"revid": 4999,
"parentid": 4998,
"user": "Alice",
"comment": "an earlier edit"
}
]
}
}
}
}
page = mock.MagicMock()
page.site = mock_site
page.name = "Impossible"
rvi = RevisionsIterator(
page, "revisions", "rv", rvstartid=5, rvstart="2001-01-15T14:56:00Z"
)
assert "rvstart" in rvi.args and "rvstartid" in rvi.args
vals = [x for x in rvi]
assert "rvstart" not in rvi.args and "rvstartid" in rvi.args
assert len(vals) == 2
assert vals[0]["comment"] == "an edit"
assert vals[1]["comment"] == "an earlier edit"
# now test the StopIteration line in PageProperty.set_iter
# by mocking a return value for a different page
mock_site.get.return_value = {
'query': {
'pages': {
'8052485': {
'pageid': '8052485',
'ns': 0,
'title': 'Impractical'
}
}
}
}
rvi = RevisionsIterator(
page, "revisions", "rv", rvstartid=5, rvstart="2001-01-15T14:56:00Z"
)
vals = [x for x in rvi]
assert len(vals) == 0
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
import time
import unittest import unittest
import unittest.mock as mock
import pytest import pytest
import logging
import requests
import responses
import json
import mwclient import mwclient
from mwclient.errors import APIError, AssertUserFailedError, ProtectedPageError, \
InvalidPageTitle
from mwclient.page import Page from mwclient.page import Page
from mwclient.client import Site
from mwclient.listing import Category
from mwclient.errors import APIError, AssertUserFailedError, ProtectedPageError, InvalidPageTitle
import unittest.mock as mock
if __name__ == "__main__": if __name__ == "__main__":
print() print()
...@@ -218,6 +214,123 @@ class TestPage(unittest.TestCase): ...@@ -218,6 +214,123 @@ class TestPage(unittest.TestCase):
with pytest.raises(mwclient.errors.EditError): with pytest.raises(mwclient.errors.EditError):
page.edit('Some text') page.edit('Some text')
@mock.patch('mwclient.client.Site')
def test_edit(self, mock_site):
mock_site.blocked = False
mock_site.rights = ['read', 'edit']
mock_site.get.return_value = {'query': {'pages': {
'-1': {'ns': 1, 'title': 'Talk:Some page/Archive 1', 'missing': ''}
}}}
page = Page(mock_site, 'Talk:Some page/Archive 1')
mock_site.post.return_value = {
'edit': {'result': 'Success', 'pageid': 1234,
'title': 'Talk:Some page/Archive 1', 'contentmodel': 'wikitext',
'oldrevid': 123456, 'newrevid': 123457,
'newtimestamp': '2024-10-02T12:34:07Z'}
}
page.edit('Some text')
mock_site.post.assert_called_once()
assert page.exists, 'Page should exist after edit'
assert page.pageid == 1234
assert page.name == 'Talk:Some page/Archive 1'
assert page.page_title == 'Some page/Archive 1'
assert page.base_title == 'Some page'
assert page.base_name == 'Talk:Some page'
assert page.contentmodel == 'wikitext'
assert page.revision == 123457
assert page.last_rev_time == time.struct_time(
(2024, 10, 2, 12, 34, 7, 2, 276, -1)
)
assert page.touched == time.struct_time(
(2024, 10, 2, 12, 34, 7, 2, 276, -1)
)
@mock.patch('mwclient.client.Site')
def test_delete(self, mock_site):
mock_site.rights = ['read', 'delete']
page_title = 'Some page'
page = Page(mock_site, page_title, info={
'contentmodel': 'wikitext',
'counter': '',
'lastrevid': 13355471,
'length': 58487,
'ns': 0,
'pageid': 728,
'pagelanguage': 'nb',
'protection': [],
'title': page_title,
'touched': '2014-09-14T21:11:52Z'
})
reason = 'Some reason'
mock_site.post.return_value = {
'delete': {'title': page_title, 'reason': reason, 'logid': 1234}
}
page.delete(reason)
mock_site.post.assert_called_once_with(
'delete', title=page_title, reason=reason, token=mock.ANY
)
assert not page.exists, 'Page should not exist after delete'
@mock.patch('mwclient.client.Site')
def test_move(self, mock_site):
mock_site.rights = ['read', 'move']
page_title = 'Some page'
page = Page(mock_site, page_title, info={
'contentmodel': 'wikitext',
'counter': '',
'lastrevid': 13355471,
'length': 58487,
'ns': 0,
'pageid': 728,
'pagelanguage': 'nb',
'protection': [],
'title': page_title,
'touched': '2014-09-14T21:11:52Z'
})
new_title = 'Some new page'
reason = 'Some reason'
mock_site.post.return_value = {
'move': {'from': page_title, 'to': new_title, 'reason': reason,
'redirectcreated': ''}
}
page.move(new_title, reason)
assert page.exists, 'Page should still exist after move'
assert page.redirect, 'Page should be a redirect after move'
@mock.patch('mwclient.client.Site')
def test_move_no_redirect(self, mock_site):
mock_site.rights = ['read', 'move']
page_title = 'Some page'
page = Page(mock_site, page_title, info={
'contentmodel': 'wikitext',
'counter': '',
'lastrevid': 13355471,
'length': 58487,
'ns': 0,
'pageid': 728,
'pagelanguage': 'nb',
'protection': [],
'title': page_title,
'touched': '2014-09-14T21:11:52Z'
})
new_title = 'Some new page'
reason = 'Some reason'
mock_site.post.return_value = {
'move': {'from': page_title, 'to': new_title, 'reason': reason}
}
page.move(new_title, reason, no_redirect=True)
assert not page.exists, 'Page should not exist after move'
assert not page.redirect, 'Page should not be a redirect after move'
class TestPageApiArgs(unittest.TestCase): class TestPageApiArgs(unittest.TestCase):
...@@ -270,7 +383,7 @@ class TestPageApiArgs(unittest.TestCase): ...@@ -270,7 +383,7 @@ class TestPageApiArgs(unittest.TestCase):
def test_get_page_text_cached(self): def test_get_page_text_cached(self):
# Check page.text() caching # Check page.text() caching
self.page.revisions = mock.Mock(return_value=iter([])) self.page.revisions = mock.Mock(return_value=iter([])) # type: ignore
self.page.text() self.page.text()
self.page.text() self.page.text()
# When cache is hit, revisions is not, so call_count should be 1 # When cache is hit, revisions is not, so call_count should be 1
...@@ -378,10 +491,10 @@ class TestPageApiArgs(unittest.TestCase): ...@@ -378,10 +491,10 @@ class TestPageApiArgs(unittest.TestCase):
'gcllimit': repr(self.page.site.api_limit), 'gcllimit': repr(self.page.site.api_limit),
} == args } == args
assert set([c.name for c in cats]) == set([ assert {c.name for c in cats} == {
'Category:1879 births', 'Category:1879 births',
'Category:1955 deaths', 'Category:1955 deaths',
]) }
if __name__ == '__main__': if __name__ == '__main__':
......
[tox] [tox]
envlist = py35,py36,py37,py38,py39,py310,py311,py312,py313,flake envlist = py36,py37,py38,py39,py310,py311,py312,py313,py314,flake,mypy
isolated_build = true
[gh-actions] [gh-actions]
python = python =
...@@ -8,21 +9,32 @@ python = ...@@ -8,21 +9,32 @@ python =
3.8: py38 3.8: py38
3.9: py39 3.9: py39
3.10: py310 3.10: py310
3.11: py311, flake 3.11: py311
3.12: py312 3.12: py312
3.13: py313 3.13: py313, flake, integration, mypy
3.14: py314
[testenv] [testenv]
deps = extras = testing
pytest commands = pytest -v --cov mwclient test
pytest-cov
responses
setuptools
mock
commands = py.test -v --cov mwclient test
[testenv:flake] [testenv:flake]
deps = deps =
flake8 flake8
commands = commands =
flake8 mwclient flake8 mwclient
[testenv:integration]
deps =
pytest
commands = pytest test/integration.py -v
[testenv:mypy]
deps =
mypy
pytest
responses
types-requests
types-setuptools
commands =
mypy