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
import logging
import warnings
from collections import OrderedDict
from typing import Optional, Callable, Union, Mapping, Any, MutableMapping, List, Dict, \
Tuple, cast, Iterable, BinaryIO, Iterator
import requests
from requests.auth import AuthBase, HTTPBasicAuth
......@@ -9,15 +11,15 @@ from requests_oauthlib import OAuth1
import mwclient.errors as errors
import mwclient.listing as listing
from mwclient.sleep import Sleepers
from mwclient.util import parse_timestamp, read_in_chunks
from mwclient._types import Cookies, Namespace, VersionTuple
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__)
USER_AGENT = 'mwclient/{} ({})'.format(__version__,
'https://github.com/mwclient/mwclient')
USER_AGENT = f'mwclient/{__version__} (https://github.com/mwclient/mwclient)'
class Site:
......@@ -29,53 +31,50 @@ class Site:
>>> wikia_site = mwclient.Site('vim.wikia.com', path='/')
Args:
host (str): The hostname of a MediaWiki instance. Must not include a
scheme (e.g. `https://`) - use the `scheme` argument instead.
path (str): The instances script path (where the `index.php` and `api.php` scripts
are located). Must contain a trailing slash (`/`). Defaults to `/w/`.
ext (str): The file extension used by the MediaWiki API scripts. Defaults to
`.php`.
pool (requests.Session): A preexisting :class:`~requests.Session` to be used when
executing API requests.
retry_timeout (int): The number of seconds to sleep for each past retry of a
failing API request. Defaults to `30`.
max_retries (int): The maximum number of retries to perform for failing API
requests. Defaults to `25`.
wait_callback (Callable): A callback function to be executed for each failing
API request.
clients_useragent (str): A prefix to be added to the default mwclient user-agent.
Should follow the pattern `'{tool_name}/{tool_version} ({contact})'`. Check
the `User-Agent policy <https://meta.wikimedia.org/wiki/User-Agent_policy>`_
host: The hostname of a MediaWiki instance. Must not include a scheme
(e.g. `https://`) - use the `scheme` argument instead.
path: The instances script path (where the `index.php` and `api.php` scripts are
located). Must contain a trailing slash (`/`). Defaults to `/w/`.
ext: The file extension used by the MediaWiki API scripts. Defaults to `.php`.
pool: A preexisting :class:`~requests.Session` to be used when executing API
requests.
retry_timeout: The number of seconds to sleep for each past retry of a failing API
request. Defaults to `30`.
max_retries: The maximum number of retries to perform for failing API requests.
Defaults to `25`.
wait_callback: A callback function to be executed for each failing API request.
clients_useragent: A prefix to be added to the default mwclient user-agent. 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.
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
more information. Defaults to `3`.
compress (bool): Whether to request and accept gzip compressed API responses.
Defaults to `True`.
force_login (bool): Whether to require authentication when editing pages. Set to
`False` to allow unauthenticated edits. Defaults to `True`.
do_init (bool): Whether to automatically initialize the :py:class:`Site` on
compress: Whether to request and accept gzip compressed API responses. Defaults to
`True`.
force_login: Whether to require authentication when editing pages. Set to `False`
to allow unauthenticated edits. Defaults to `True`.
do_init: Whether to automatically initialize the :py:class:`Site` on
initialization. When set to `False`, the :py:class:`Site` must be initialized
manually using the :py:meth:`.site_init` method. Defaults to `True`.
httpauth (Union[tuple[basestring, basestring], requests.auth.AuthBase]): An
authentication method to be used when making API requests. This can be either
an authentication object as provided by the :py:mod:`requests` library, or a
tuple in the form `{username, password}`. Usernames and passwords provided as
text strings are encoded as UTF-8. If dealing with a server that cannot
handle UTF-8, please provide the username and password already encoded with
the appropriate encoding.
reqs (Dict[str, Any]): Additional arguments to be passed to the
httpauth: An authentication method to be used when making API requests. This can
be either an authentication object as provided by the :py:mod:`requests`
library, or a tuple in the form `{username, password}`. Usernames and
passwords provided as text strings are encoded as UTF-8. If dealing with a
server that cannot handle UTF-8, please provide the username and password
already encoded with the appropriate encoding.
connection_options: Additional arguments to be passed to 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.
consumer_token (str): OAuth1 consumer key for owner-only consumers.
consumer_secret (str): OAuth1 consumer secret for owner-only consumers.
access_token (str): OAuth1 access key for owner-only consumers.
access_secret (str): OAuth1 access secret for owner-only consumers.
client_certificate (Union[str, tuple[str, str]]): A client certificate to be added
consumer_token: OAuth1 consumer key for owner-only consumers.
consumer_secret: OAuth1 consumer secret for owner-only consumers.
access_token: OAuth1 access key for owner-only consumers.
access_secret: OAuth1 access secret for owner-only consumers.
client_certificate: A client certificate to be added
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.
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`.
Raises:
......@@ -91,21 +90,56 @@ class Site:
}
api_limit = 500
def __init__(self, host, path='/w/', ext='.php', pool=None, retry_timeout=30,
max_retries=25, wait_callback=lambda *x: None, clients_useragent=None,
max_lag=3, compress=True, force_login=True, do_init=True, httpauth=None,
reqs=None, consumer_token=None, consumer_secret=None, access_token=None,
access_secret=None, client_certificate=None, custom_headers=None,
scheme='https'):
def __init__(
self,
host: str,
path: str = '/w/',
ext: str = '.php',
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
self.host = host
self.path = path
self.ext = ext
self.credentials = None
self.credentials = None # type: Optional[Tuple[str, str, Optional[str]]]
self.compress = compress
self.max_lag = str(max_lag)
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
if 'timeout' not in self.requests:
self.requests['timeout'] = 30 # seconds
......@@ -129,19 +163,18 @@ class Site:
self.sleepers = Sleepers(max_retries, retry_timeout, wait_callback)
# 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.groups = [] # Groups current user belongs to
self.rights = [] # Rights current user has
self.tokens = {} # Edit tokens of the current user
self.version = None
self.groups = [] # type: List[str] # Groups current user is in
self.rights = [] # type: List[str] # Rights current user has
self.tokens = {} # type: Dict[str, str] # Edit tokens of the current user
self.version = None # type: Optional[VersionTuple]
self.namespaces = self.default_namespaces
self.writeapi = False
self.namespaces = self.default_namespaces # type: Dict[int, str]
# Setup connection
if pool is None:
self.connection = requests.Session()
self.connection = requests.Session() # type: requests.Session
self.connection.auth = auth
if client_certificate:
self.connection.cert = client_certificate
......@@ -185,7 +218,7 @@ class Site:
if e.args[0] not in {'unknown_action', 'readapidenied'}:
raise
def site_init(self):
def site_init(self) -> None:
"""Populates the object with information about the current user and site. This is
done automatically when creating the object, unless explicitly disabled using the
`do_init=False` constructor argument."""
......@@ -209,7 +242,6 @@ class Site:
namespace['id']: namespace.get('*', '')
for namespace in meta['query']['namespaces'].values()
}
self.writeapi = 'writeapi' in self.site
self.version = self.version_tuple_from_generator(self.site['generator'])
......@@ -224,7 +256,9 @@ class Site:
self.initialized = True
@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.
Example:
......@@ -232,39 +266,57 @@ class Site:
(1, 5, 1)
Args:
string (str): The MediaWiki Generator string.
prefix (str): The expected prefix of the string.
string: The MediaWiki Generator string.
prefix: The expected prefix of the string.
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):
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):
"""Split the string on the first non-digit character.
Args:
version (str): The version string (without the prefix).
Returns:
A tuple of the digit part as int and, if available,
the rest of the string.
Yields:
str: The individual segments of the version string.
"""
i = 0
while i < len(s):
if s[i] < '0' or s[i] > '9':
break
i += 1
if s[i:]:
return (int(s[:i]), s[i:], )
else:
return (int(s[:i]), )
current_segment = ''
for curr_char in version:
if curr_char in "-+_.":
yield current_segment
current_segment = ''
elif current_segment and (
(current_segment[-1].isdigit() and curr_char.isalpha())
or (current_segment[-1].isalpha() and curr_char.isdigit())
):
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:
raise errors.MediaWikiVersionError('Unknown MediaWiki {}'
.format('.'.join(version)))
raise errors.MediaWikiVersionError(f'Unknown MediaWiki {".".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
......@@ -276,45 +328,66 @@ class Site:
-1: 'Special', -2: 'Media'
}
def __repr__(self):
return "<%s object '%s%s'>" % (self.__class__.__name__, self.host, self.path)
def __repr__(self) -> str:
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.
This is just a shorthand for calling api() with http_method='GET'.
All arguments will be passed on.
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:
The raw response from the API call, as a dictionary.
"""
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.
This is just a shorthand for calling api() with http_method='POST'.
All arguments will be passed on.
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:
The raw response from the API call, as a dictionary.
"""
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.
All arguments will be passed on.
Args:
action (str): The MediaWiki API action to be performed.
http_method (str): The HTTP method to use.
action: The MediaWiki API action to be performed.
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:
To get coordinates from the GeoData MediaWiki extension at English Wikipedia:
......@@ -323,9 +396,10 @@ class Site:
>>> result = site.api('query', prop='coordinates', titles='Oslo|Copenhagen')
>>> for page in result['query']['pages'].values():
... if 'coordinates' in page:
... print('{} {} {}'.format(page['title'],
... page['coordinates'][0]['lat'],
... page['coordinates'][0]['lon']))
... title = page['title']
... lat = page['coordinates'][0]['lat']
... lon = page['coordinates'][0]['lon']
... print(f'{title} {lat} {lon}')
Oslo 59.95 10.75
Copenhagen 55.6761 12.5683
......@@ -334,6 +408,8 @@ class Site:
"""
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:
kwargs['continue'] = ''
if action == 'query':
......@@ -355,16 +431,20 @@ class Site:
if self.handle_api_result(info, sleeper=sleeper):
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
necessary.
Args:
info (dict): The API result.
kwargs (dict): Additional arguments to be passed when raising an
info: The API result.
kwargs: Additional arguments to be passed when raising an
:class:`errors.APIError`.
sleeper (sleep.Sleeper): A :class:`~sleep.Sleeper` instance to use when
sleeping.
sleeper: A :class:`~sleep.Sleeper` instance to use when sleeping.
Returns:
`False` if the given API response contains an exception, else `True`.
......@@ -376,13 +456,14 @@ class Site:
try:
userinfo = info['query']['userinfo']
except KeyError:
userinfo = ()
userinfo = {}
if 'blockedby' in userinfo:
self.blocked = (userinfo['blockedby'], userinfo.get('blockreason', ''))
else:
self.blocked = False
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:
for module, warning in info['warnings'].items():
if '*' in warning:
......@@ -416,7 +497,7 @@ class Site:
return True
@staticmethod
def _query_string(*args, **kwargs):
def _query_string(*args: Tuple[str, Any], **kwargs: Any) -> Dict[str, Any]:
kwargs.update(args)
qs1 = [
(k, v) for k, v in kwargs.items() if k not in {'wpEditToken', 'token'}
......@@ -426,7 +507,14 @@ class Site:
]
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.
......@@ -439,11 +527,11 @@ class Site:
HTTP responses.
Args:
script (str): Script name, usually 'api'.
data (dict): Post data
files (dict): Files to upload
retry_on_error (bool): Retry on connection error
http_method (str): The HTTP method, defaults to 'POST'
script: Script name, usually 'api'.
data: Post data
files: Files to upload
retry_on_error: Retry on connection error
http_method: The HTTP method, defaults to 'POST'
Returns:
The raw text response.
......@@ -464,22 +552,20 @@ class Site:
scheme = self.scheme
host = self.host
if isinstance(host, (list, tuple)):
warnings.warn(
if isinstance(host, (list, tuple)): # type: ignore[unreachable]
warnings.warn( # type: ignore[unreachable]
'Specifying host as a tuple is deprecated as of mwclient 0.10.1. '
+ 'Please use the new scheme argument instead.',
DeprecationWarning
)
scheme, host = host
url = '{scheme}://{host}{path}{script}{ext}'.format(scheme=scheme, host=host,
path=self.path, script=script,
ext=self.ext)
url = f'{scheme}://{host}{self.path}{script}{self.ext}'
while True:
toraise = None
toraise = None # type: Optional[Union[requests.RequestException, str]]
wait_time = 0
args = {'files': files, 'headers': headers}
args = {'files': files, 'headers': headers} # type: Dict[str, Any]
for k, v in self.requests.items():
args[k] = v
if http_method == 'GET':
......@@ -490,21 +576,24 @@ class Site:
try:
stream = self.connection.request(http_method, url, **args)
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. '
'Waiting for {} seconds'.format(wait_time))
'Waiting for %d seconds', wait_time)
# fall through to the sleep
elif stream.status_code == 200:
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()
else:
if not retry_on_error:
stream.raise_for_status()
log.warning('Received {status} response: {text}. '
'Retrying in a moment.'
.format(status=stream.status_code,
text=stream.text))
log.warning('Received %d response: %s. Retrying in a moment.',
stream.status_code, stream.text)
toraise = "stream"
# fall through to the sleep
......@@ -527,21 +616,30 @@ class Site:
except errors.MaximumRetriesExceeded:
if toraise == "stream":
stream.raise_for_status()
elif toraise:
elif toraise and isinstance(toraise, BaseException):
raise toraise
else:
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.
Args:
action (str): The MediaWiki API action to perform.
http_method (str): The HTTP method to use in the request.
retry_on_error (bool): Whether to retry API call on connection errors.
*args (Tuple[str, Any]): Arguments to be passed to the `api.php` script as
data.
**kwargs (Any): Arguments to be passed to the `api.php` script as data.
action: The MediaWiki API action to perform.
http_method: The HTTP method to use in the request.
retry_on_error: Whether to retry API call on connection errors.
*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.php` script as data.
Returns:
The API response.
......@@ -564,21 +662,29 @@ class Site:
http_method=http_method)
try:
return json.loads(res, object_pairs_hook=OrderedDict)
return cast(Dict[str, Any], json.loads(res, object_pairs_hook=OrderedDict))
except ValueError:
if res.startswith('MediaWiki API is not enabled for this site.'):
raise errors.APIDisabledError
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.
Args:
action (str): The MediaWiki API action to perform.
http_method (str): The HTTP method to use in the request.
*args (Tuple[str, Any]): Arguments to be passed to the `index.php` script as
data.
**kwargs (Any): Arguments to be passed to the `index.php` script as data.
action: The MediaWiki API action to perform.
http_method: The HTTP method to use in the request.
*args: Tupled key-value pairs to be passed to the `index.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 `index.php` script as data.
Returns:
The API response.
......@@ -597,20 +703,26 @@ class Site:
data = self._query_string(*args, **kwargs)
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.
Args:
major (int): The required major version.
minor (int): The required minor version.
revision (int): The required revision.
raise_error (bool): Whether to throw an error if the version of the current
wiki is below the required version. Defaults to `True`.
major: The required major version.
minor: The required minor version.
revision: The required revision.
raise_error: Whether to throw an error if the version of the current wiki is
below the required version. Defaults to `True`.
Returns:
`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
`raise_error=None` then nothing is returned.
`True`. If `raise_error` is `False` and the version is below the required
version, `None` is returned.
Raises:
errors.MediaWikiVersionError: The current wiki is below the required version
......@@ -625,27 +737,34 @@ class Site:
"""
if self.version is None:
if raise_error is None:
return
# FIXME: Replace this with a specific error
raise RuntimeError('Site %s has not yet been initialized' % repr(self))
warnings.warn( # type: ignore[unreachable]
'Passing raise_error=None to require is deprecated and will be '
'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 self.version[:2] >= (major, minor):
return True
elif raise_error:
raise errors.MediaWikiVersionError(
'Requires version {required[0]}.{required[1]}, '
'current version is {current[0]}.{current[1]}'
.format(required=(major, minor),
current=(self.version[:2]))
)
f'Requires version {major}.{minor}, '
f'current version is {self.version[0]}.{self.version[1]}')
else:
return False
else:
raise NotImplementedError
# 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.
......@@ -654,11 +773,13 @@ class Site:
... except mwclient.errors.NoSpecifiedEmail:
... print('User does not accept email, or has no email address.')
API doc: https://www.mediawiki.org/wiki/API:Email
Args:
user (str): User name of the recipient
text (str): Body of the email
subject (str): Subject of the email
cc (bool): True to send a copy of the email to yourself (default is False)
user: Username of the recipient
text: Body of the email
subject: Subject of the email
cc: True to send a copy of the email to yourself (default is False)
Returns:
Dictionary of the JSON response
......@@ -676,11 +797,17 @@ class Site:
except errors.APIError as e:
if e.args[0] == 'noemail':
raise errors.NoSpecifiedEmail(user, e.args[1])
raise errors.EmailError(*e)
raise errors.EmailError(*e) # type: ignore[misc]
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
nothing if the login was successful, but raises and error if it was not.
......@@ -697,11 +824,11 @@ class Site:
with a user who has userrights permission (a bureaucrat for eg.).
Args:
username (str): MediaWiki username
password (str): MediaWiki password
cookies (dict): Custom cookies to include with the log-in request.
domain (str): Sends domain name for authentication; used by some
MediaWiki plug-ins like the 'LDAP Authentication' extension.
username: MediaWiki username
password: MediaWiki password
cookies: Custom cookies to include with the log-in request.
domain: Sends domain name for authentication; used by some MediaWiki plug-ins
like the 'LDAP Authentication' extension.
Raises:
LoginError (mwclient.errors.LoginError): Login failed, the reason can be
......@@ -754,7 +881,7 @@ class Site:
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
True if it's a success or the returned response if it's a multi-steps
......@@ -764,11 +891,11 @@ class Site:
>>> try:
... site.clientlogin(username='myusername', password='secret')
... except mwclient.errors.LoginError as e:
... print('Could not login to MediaWiki: %s' % e)
... print(f'Could not login to MediaWiki: {e}' )
Args:
cookies (dict): Custom cookies to include with the log-in request.
**kwargs (dict): Custom vars used for clientlogin as:
cookies: Custom cookies to include with the log-in request.
**kwargs: Custom vars used for clientlogin as:
- loginmergerequestfields
- loginpreservestate
- loginreturnurl,
......@@ -779,6 +906,10 @@ class Site:
`username` and `password`
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:
LoginError (mwclient.errors.LoginError): Login failed, the reason can be
obtained from e.code and e.info (where e is the exception object) and
......@@ -798,40 +929,40 @@ class Site:
if cookies:
self.connection.cookies.update(cookies)
if kwargs:
# Try to login using the scheme for MW 1.27+. If the wiki is read protected,
# it is not possible to get the wiki version upfront using the API, so we just
# have to try. If the attempt fails, we try the old method.
if 'logintoken' not in kwargs:
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 not kwargs:
# TODO: Check if we should raise an error here. It's not clear what the
# expected behavior is when no kwargs are passed. To update the
# cookies, the user can update the connection object directly.
return
if 'logincontinue' not in kwargs and 'loginreturnurl' not in kwargs:
# should be great if API didn't require this...
kwargs['loginreturnurl'] = '%s://%s' % (self.scheme, self.host)
if 'logintoken' not in kwargs:
kwargs['logintoken'] = self.get_token('login')
while True:
login = self.post('clientlogin', **kwargs)
status = login['clientlogin'].get('status')
if status == 'PASS':
return True
elif status in ('UI', 'REDIRECT'):
return login['clientlogin']
else:
raise errors.LoginError(self, status,
login['clientlogin'].get('message'))
if 'logincontinue' not in kwargs and 'loginreturnurl' not in kwargs:
kwargs['loginreturnurl'] = f'{self.scheme}://{self.host}'
response = self.post('clientlogin', **kwargs)
status = response['clientlogin'].get('status')
if status == 'PASS':
self.site_init()
return True
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`.
Args:
type (str): The type of token to request.
force (bool): Force the request of a new token, even if a token of that type
has already been cached.
title (str): The page title for which to request a token. Only used for
MediaWiki versions below 1.24.
type: The type of token to request.
force: Force the request of a new token, even if a token of that type has
already been cached.
title: The page title for which to request a token. Only used for MediaWiki
versions below 1.24.
Returns:
A MediaWiki token of the requested `type`.
......@@ -839,7 +970,7 @@ class Site:
Raises:
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 majority of older tokens, like edittoken and movetoken.
if type not in self.AVAILABLE_TOKEN_TYPES:
......@@ -849,8 +980,7 @@ class Site:
self.tokens[type] = '0'
if self.tokens.get(type, '0') == '0' or force:
if self.version is None or self.version[:2] >= (1, 24):
if self.version is None or self.require(1, 24, raise_error=False):
# 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
# protected, and we're trying to fetch a login token.
......@@ -861,7 +991,7 @@ class Site:
# 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
# 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:
if title is None:
......@@ -871,7 +1001,7 @@ class Site:
prop='info', intoken=type)
for i in info['query']['pages'].values():
if i['title'] == title:
self.tokens[type] = i['%stoken' % type]
self.tokens[type] = i[f'{type}token']
return self.tokens[type]
......@@ -1230,25 +1360,35 @@ class Site:
res = res.get('userrights', {})
return {'added': res.get('added', []), 'removed': res.get('removed', [])}
def upload(self, file=None, filename=None, description='', ignore=False,
file_size=None, url=None, filekey=None, comment=None):
def upload(
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.
Note that one of `file`, `filekey` and `url` must be specified, but not
more than one. For normal uploads, you specify `file`.
API doc: https://www.mediawiki.org/wiki/API:Upload
Args:
file (str): File object or stream to upload.
filename (str): Destination filename, don't include namespace
prefix like 'File:'
description (str): Wikitext for the file description page.
ignore (bool): True to upload despite any warnings.
file_size (int): Deprecated in mwclient 0.7
url (str): URL to fetch the file from.
filekey (str): Key that identifies a previous upload that was
stashed temporarily.
comment (str): Upload comment. Also used as the initial page text
for new files if `description` is not specified.
file: File object or stream to upload.
filename: Destination filename, don't include namespace prefix like 'File:'
description: Wikitext for the file description page.
ignore: True to upload despite any warnings.
file_size: Deprecated in mwclient 0.7
url: URL to fetch the file from.
filekey: Key that identifies a previous upload that was stashed temporarily.
comment: Upload comment. Also used as the initial page text for new files if
`description` is not specified.
Example:
......@@ -1261,6 +1401,7 @@ class Site:
Raises:
errors.InsufficientPermission
requests.exceptions.HTTPError
errors.FileExists: The file already exists and `ignore` is `False`.
"""
if file_size is not None:
......@@ -1293,10 +1434,15 @@ class Site:
if not hasattr(file, 'read'):
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)
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)
predata = {
......@@ -1315,7 +1461,7 @@ class Site:
# sessionkey was renamed to filekey in MediaWiki 1.18
# https://phabricator.wikimedia.org/rMW5f13517e36b45342f228f3de4298bb0fe186995d
if self.version[:2] < (1, 18):
if not self.require(1, 18, raise_error=False):
predata['sessionkey'] = filekey
else:
predata['filekey'] = filekey
......@@ -1323,7 +1469,6 @@ class Site:
postdata = predata
files = None
if file is not None:
# Workaround for https://github.com/mwclient/mwclient/issues/65
# ----------------------------------------------------------------
# Since the filename in Content-Disposition is not interpreted,
......@@ -1338,13 +1483,29 @@ class Site:
if not info:
info = {}
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
if file is not None:
file.close()
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.
This method is called by `Site.upload` if you are connecting to a newer
......@@ -1352,8 +1513,11 @@ class Site:
method directly.
Args:
file (file-like object): File object or stream to upload.
params (dict): Dict containing upload parameters.
file: File object or stream to upload.
filename: Destination filename.
ignorewarnings: True to upload despite any warnings.
comment: Upload comment.
text: Initial page text for new files.
"""
image = self.Images[filename]
......@@ -1379,7 +1543,7 @@ class Site:
data = self.raw_call('api', params, files={'chunk': chunk})
info = json.loads(data)
if self.handle_api_result(info, kwargs=params, sleeper=sleeper):
response = info.get('upload', {})
response = info.get('upload', {}) # type: Dict[str, Any]
break
offset += chunk.tell()
......@@ -1405,20 +1569,29 @@ class Site:
params['text'] = text
return self.post('upload', **params)
def parse(self, text=None, title=None, page=None, prop=None,
redirects=False, mobileformat=False):
def parse(
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.
API doc: https://www.mediawiki.org/wiki/API:Parse
Args:
text (str): Text to parse.
title (str): Title of page the text belongs to.
page (str): The name of a page to parse. Cannot be used together with text
text: Text to parse.
title: Title of page the text belongs to.
page: The name of a page to parse. Cannot be used together with text
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.
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`.
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`.
Returns:
......@@ -1444,7 +1617,12 @@ class Site:
# def unblock: 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
be given.
The ``rcid`` and ``revid`` arguments may be obtained using the
......@@ -1453,9 +1631,9 @@ class Site:
API doc: https://www.mediawiki.org/wiki/API:Patrol
Args:
rcid (int): The recentchanges ID to patrol.
revid (int): The revision ID to patrol.
tags (str): Change tags to apply to the entry in the patrol log. Multiple
rcid: The recentchanges ID to patrol.
revid: The revision ID to patrol.
tags: Change tags to apply to the entry in the patrol log. Multiple
tags can be given, by separating them with the pipe (|) character.
Returns:
......@@ -1484,12 +1662,31 @@ class Site:
return result['patrol']
# Lists
def allpages(self, start=None, prefix=None, namespace='0', filterredir='all',
minsize=None, maxsize=None, prtype=None, prlevel=None,
limit=None, dir='ascending', filterlanglinks='all', generator=True,
end=None):
"""Retrieve all pages on the wiki as a generator."""
def allpages(
self,
start: Optional[str] = None,
prefix: Optional[str] = None,
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)
kwargs = dict(listing.List.generate_kwargs(
pfx, ('from', start), ('to', end), prefix=prefix,
......@@ -1498,60 +1695,143 @@ class Site:
filterlanglinks=filterlanglinks,
))
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)
def allimages(self, start=None, prefix=None, minsize=None, maxsize=None, limit=None,
dir='ascending', sha1=None, sha1base36=None, generator=True, end=None):
"""Retrieve all images on the wiki as a generator."""
def allimages(
self,
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)
kwargs = dict(listing.List.generate_kwargs(
pfx, ('from', start), ('to', end), prefix=prefix,
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',
**kwargs)
def alllinks(self, start=None, prefix=None, unique=False, prop='title',
namespace='0', limit=None, generator=True, end=None):
"""Retrieve a list of all links on the wiki as a generator."""
def alllinks(
self,
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)
kwargs = dict(listing.List.generate_kwargs(pfx, ('from', start), ('to', end),
prefix=prefix,
prop=prop, namespace=namespace))
if unique:
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)
def allcategories(self, start=None, prefix=None, dir='ascending', limit=None,
generator=True, end=None):
"""Retrieve all categories on the wiki as a generator."""
def allcategories(
self,
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)
kwargs = dict(listing.List.generate_kwargs(pfx, ('from', start), ('to', end),
prefix=prefix, dir=dir))
return listing.List.get_list(generator)(self, 'allcategories', 'ac', limit=limit,
**kwargs)
return listing.List.get_list(generator)(self, 'allcategories', 'ac',
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,
witheditsonly=False, activeusers=False, rights=None, end=None):
"""Retrieve all users on the wiki as a generator."""
API doc: https://www.mediawiki.org/wiki/API:Allusers
"""
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('au', ('from', start), ('to', end),
prefix=prefix,
group=group, prop=prop,
rights=rights,
witheditsonly=witheditsonly,
activeusers=activeusers))
return listing.List(self, 'allusers', 'au', limit=limit, **kwargs)
def blocks(self, start=None, end=None, dir='older', ids=None, users=None, limit=None,
prop='id|user|by|timestamp|expiry|reason|flags'):
return listing.List(self, 'allusers', 'au', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
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.
API doc: https://www.mediawiki.org/wiki/API:Blocks
......@@ -1577,22 +1857,50 @@ class Site:
"""
# 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,
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,
limit=None, prop='user|comment'):
API doc: https://www.mediawiki.org/wiki/API:Deletedrevs
"""
# 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,
namespace=namespace, prop=prop))
return listing.List(self, 'deletedrevs', 'dr', limit=limit, **kwargs)
def exturlusage(self, query, prop=None, protocol='http', namespace=None, limit=None):
return listing.List(self, 'deletedrevs', 'dr', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
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,
as a generator.
API doc: https://www.mediawiki.org/wiki/API:Exturlusage
This API call mirrors the Special:LinkSearch function on-wiki.
Query can be a domain like 'bbc.co.uk'.
......@@ -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,
protocol=protocol,
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,
dir='older', user=None, title=None, limit=None, action=None):
"""Retrieve logevents as a generator."""
API doc: https://www.mediawiki.org/wiki/API:Logevents
"""
kwargs = dict(listing.List.generate_kwargs('le', prop=prop, type=type,
start=start, end=end, dir=dir,
user=user, title=title, action=action))
return listing.List(self, 'logevents', 'le', limit=limit, **kwargs)
def checkuserlog(self, user=None, target=None, limit=10, dir='older',
start=None, end=None):
return listing.List(self, 'logevents', 'le', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
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."""
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
kwargs = dict(listing.List.generate_kwargs('cul', target=target, start=start,
end=end, dir=dir, user=user))
return listing.NestedList('entries', self, 'checkuserlog', 'cul',
limit=limit, **kwargs)
return listing.NestedList(
'entries',
self,
'checkuserlog',
'cul',
max_items=max_items,
api_chunk_size=api_chunk_size,
**kwargs,
)
# 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.
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.
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))
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,
prop=None, show=None, limit=None, type=None, toponly=None):
"""List recent changes to the wiki, à la Special:Recentchanges.
API doc: https://www.mediawiki.org/wiki/API: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,
namespace=namespace, prop=prop,
show=show, type=type,
toponly='1' if toponly else None))
return listing.List(self, 'recentchanges', 'rc', limit=limit, **kwargs)
def revisions(self, revids, prop='ids|timestamp|flags|comment|user'):
return listing.List(self, 'recentchanges', 'rc', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
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.
See also the `Page.revisions()` method.
......@@ -1670,8 +2046,8 @@ class Site:
... print(revision['*'])
Args:
revids (list): A list of (max 50) revisions.
prop (str): Which properties to get for each revision.
revids: A list of (max 50) revisions.
prop: Which properties to get for each revision.
Returns:
A list of revisions
......@@ -1692,7 +2068,16 @@ class Site:
revisions.append(revision)
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.
API doc: https://www.mediawiki.org/wiki/API:Search
......@@ -1702,41 +2087,60 @@ class Site:
... print(result.get('title'))
Args:
search (str): The query string
namespace (int): The namespace to search (default: 0)
what (str): Search scope: 'text' for fulltext, or 'title' for titles only.
Depending on the search backend,
both options may not be available.
For instance
`CirrusSearch <https://www.mediawiki.org/wiki/Help:CirrusSearch>`_
doesn't support 'title', but instead provides an "intitle:"
query string filter.
redirects (bool): Include redirect pages in the search
(option removed in MediaWiki 1.23).
search: The query string
namespace: The namespace to search (default: 0)
what: Search scope: 'text' for fulltext, or 'title' for titles only.
Depending on the search backend,
both options may not be available.
For instance
`CirrusSearch <https://www.mediawiki.org/wiki/Help:CirrusSearch>`_
doesn't support 'title', but instead provides an "intitle:"
query string filter.
redirects: Include redirect pages in the search
(option removed in MediaWiki 1.23).
Returns:
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,
namespace=namespace, what=what))
if redirects:
kwargs['srredirects'] = '1'
return listing.List(self, 'search', 'sr', limit=limit, **kwargs)
def usercontributions(self, user, start=None, end=None, dir='older', namespace=None,
prop=None, show=None, limit=None, uselang=None):
return listing.List(self, 'search', 'sr', max_items=max_items,
api_chunk_size=api_chunk_size, **kwargs)
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.
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,
dir=dir, namespace=namespace,
prop=prop, show=show))
return listing.List(self, 'usercontribs', 'uc', limit=limit, uselang=uselang,
**kwargs)
def users(self, users, prop='blockinfo|groups|editcount'):
return listing.List(self, 'usercontribs', 'uc', max_items=max_items,
api_chunk_size=api_chunk_size, uselang=uselang, **kwargs)
def users(
self,
users: Iterable[str],
prop: str = 'blockinfo|groups|editcount'
) -> listing.List:
"""
Get information about a list of users.
......@@ -1745,55 +2149,99 @@ class Site:
return listing.List(self, 'users', 'us', ususers='|'.join(users), usprop=prop)
def watchlist(self, allrev=False, start=None, end=None, namespace=None, dir='older',
prop=None, show=None, limit=None):
def watchlist(
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.
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,
namespace=namespace, dir=dir,
prop=prop, show=show))
if allrev:
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.
API doc: https://www.mediawiki.org/wiki/API:Expandtemplates
Args:
text (str): Wikitext to convert.
title (str): Title of the page.
generatexml (bool): Generate the XML parse tree. Defaults to `False`.
text: Wikitext to convert.
title: Title of the page.
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 = {}
if title is not None:
kwargs['title'] = title
wikitext = str(result['expandtemplates']['wikitext'])
if generatexml:
# FIXME: Deprecated and replaced by `prop=parsetree`.
kwargs['generatexml'] = '1'
result = self.post('expandtemplates', text=text, **kwargs)
parsetree = str(result['expandtemplates']['parsetree'])
return wikitext, parsetree
return wikitext
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:
return result['expandtemplates']['*'], result['parsetree']['*']
else:
return result['expandtemplates']['*']
parsetree = str(result['parsetree']['*'])
return wikitext, parsetree
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.
API doc: https://semantic-mediawiki.org/wiki/Ask_API
Args:
query (str): The SMW query to be executed.
query: The SMW query to be executed.
Returns:
Generator for retrieving all search results, with each answer as a dictionary.
......@@ -1808,16 +2256,16 @@ class Site:
>>> print(title)
>>> print(data)
"""
kwargs = {}
if title is None:
kwargs = {} # type: Dict[str, Any]
if title is not None:
kwargs['title'] = title
offset = 0
offset = 0 # type: Optional[int]
while offset is not None:
results = self.raw_api('ask', query='{query}|offset={offset}'.format(
query=query, offset=offset), http_method='GET', **kwargs)
results = self.raw_api('ask', query=f'{query}|offset={offset}',
http_method='GET', **kwargs)
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', [])
if isinstance(answers, dict):
......@@ -1826,5 +2274,4 @@ class Site:
# with the page title as key.
answers = [answer for answer in answers.values()]
for answer in answers:
yield answer
yield from answers
from typing import Any, TYPE_CHECKING, Optional, cast
if TYPE_CHECKING:
import mwclient.page
class MwClientError(RuntimeError):
"""Base class for all mwclient errors."""
pass
class MediaWikiVersionError(MwClientError):
"""The version of MediaWiki is not supported."""
pass
class APIDisabledError(MwClientError):
"""The API is disabled on the wiki."""
pass
class MaximumRetriesExceeded(MwClientError):
"""The maximum number of retries for a request has been exceeded."""
pass
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.info = info
super(APIError, self).__init__(code, info, kwargs)
super().__init__(code, info, kwargs)
class UserNotFound(APIError):
......@@ -31,38 +48,80 @@ class UserCreateError(APIError):
class InsufficientPermission(MwClientError):
"""Raised when the user does not have sufficient permissions to perform an
action."""
pass
class UserBlocked(InsufficientPermission):
"""Raised when attempting to perform an action while blocked."""
pass
class EditError(MwClientError):
"""Base class for errors related to editing pages."""
pass
class ProtectedPageError(EditError, InsufficientPermission):
def __init__(self, page, code=None, info=None):
"""Raised when attempting to edit a protected page.
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.code = code
self.info = info
def __str__(self):
def __str__(self) -> str:
if self.info is not None:
return self.info
return 'You do not have the "edit" right.'
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):
super(LoginError, self).__init__(
class LoginError(MwClientError):
"""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,
{'result': code, 'reason': info} # For backwards-compability
)
......@@ -70,43 +129,54 @@ class LoginError(MwClientError):
self.code = code
self.info = info
def __str__(self):
def __str__(self) -> str:
return self.info
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
class AssertUserFailedError(MwClientError):
def __init__(self):
super(AssertUserFailedError, self).__init__((
"""Raised when the user assertion fails."""
def __init__(self) -> None:
super().__init__(
'By default, mwclient protects you from accidentally editing '
'without being logged in. If you actually want to edit without '
'logging in, you can set force_login on the Site object to False.'
))
)
def __str__(self):
return self.args[0]
def __str__(self) -> str:
return cast(str, self.args[0])
class EmailError(MwClientError):
"""Base class for email errors."""
pass
class NoSpecifiedEmail(EmailError):
pass
class NoWriteApi(MwClientError):
"""Raised when trying to email a user who has not specified an email"""
pass
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):
super(InvalidResponse, self).__init__((
def __init__(self, response_text: Optional[str] = None) -> None:
super().__init__((
'Did not get a valid JSON response from the server. Check that '
'you used the correct hostname. If you did, the server might '
'be wrongly configured or experiencing temporary problems.'),
......@@ -114,9 +184,10 @@ class InvalidResponse(MwClientError):
)
self.response_text = response_text
def __str__(self):
return self.args[0]
def __str__(self) -> str:
return cast(str, self.args[0])
class InvalidPageTitle(MwClientError):
"""Raised when an invalid page title is used."""
pass
import io
from typing import Optional, Mapping, Any, overload
import mwclient.listing
import mwclient.page
from mwclient._types import Namespace
from mwclient.util import handle_limit
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):
super(Image, self).__init__(
def __init__(
self,
site: 'mwclient.client.Site',
name: str,
info: Optional[Mapping[str, Any]] = None
) -> None:
super().__init__(
site, name, info, extra_properties={
'imageinfo': (
('iiprop',
......@@ -16,7 +38,7 @@ class Image(mwclient.page.Page):
self.imagerepository = self._info.get('imagerepository', '')
self.imageinfo = self._info.get('imageinfo', ({}, ))[0]
def imagehistory(self):
def imagehistory(self) -> 'mwclient.listing.PageProperty':
"""
Get file revision info for the given file.
......@@ -27,8 +49,16 @@ class Image(mwclient.page.Page):
iiprop='timestamp|user|comment|url|size|sha1|metadata|mime|archivename'
)
def imageusage(self, namespace=None, filterredir='all', redirect=False,
limit=None, generator=True):
def imageusage(
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.
......@@ -38,21 +68,53 @@ class Image(mwclient.page.Page):
kwargs = dict(mwclient.listing.List.generate_kwargs(
prefix, title=self.name, namespace=namespace, filterredir=filterredir
))
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
if redirect:
kwargs['%sredirect' % prefix] = '1'
kwargs[f'{prefix}redirect'] = '1'
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.
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
directly to the stream. Otherwise the file content will be stored in memory
......@@ -64,19 +126,16 @@ class Image(mwclient.page.Page):
... image.download(fd)
Args:
destination (file object): Destination file
destination: Destination file
"""
url = self.imageinfo['url']
if destination is not None:
res = self.site.connection.get(url, stream=True)
for chunk in res.iter_content(1024):
destination.write(chunk)
return None
else:
return self.site.connection.get(url).content
def __repr__(self):
return "<%s object '%s' for %s>" % (
self.__class__.__name__,
self.name.encode('utf-8'),
self.site
)
def __repr__(self) -> str:
return f"<{self.__class__.__name__} object '{self.name}' for {self.site}>"
from mwclient.util import parse_timestamp
import mwclient.page
from typing import ( # noqa: F401
Optional, Tuple, Any, Union, Iterator, Mapping, Iterable, Type, Dict
)
import mwclient.image
import mwclient.page
from mwclient._types import Namespace
from mwclient.util import parse_timestamp, handle_limit
class List:
......@@ -9,11 +14,27 @@ class List:
This is a class providing lazy iteration. This means that the
content is loaded in chunks as long as the response hints at
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,
limit=None, return_values=None, max_items=None,
*args, **kwargs):
def __init__(
self,
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
self.site = site
self.list_name = list_name
......@@ -23,23 +44,28 @@ class List:
kwargs.update(args)
self.args = kwargs
if limit is None:
limit = site.api_limit
self.args[self.prefix + 'limit'] = str(limit)
(max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size)
# 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.max_items = max_items
self._iter = iter(range(0))
self._iter = iter(range(0)) # type: Iterator[Any]
self.last = False
self.result_member = list_name
self.return_values = return_values
def __iter__(self):
def __iter__(self) -> 'List':
return self
def __next__(self):
def __next__(self) -> Any:
if self.max_items is not None:
if self.count >= self.max_items:
raise StopIteration
......@@ -64,12 +90,12 @@ class List:
if isinstance(self, GeneratorList):
return item
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:
return item[self.return_values]
return item
def load_chunk(self):
def load_chunk(self) -> None:
"""Query a new chunk of data
If the query is empty, `raise StopIteration`.
......@@ -77,11 +103,8 @@ class List:
Else, update the iterator accordingly.
If 'continue' is in the response, it is added to `self.args`
(new style continuation, added in MediaWiki 1.21).
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).
(new style continuation, added in MediaWiki 1.21, default
since MediaWiki 1.26).
Else, set `self.last` to True.
"""
......@@ -102,14 +125,10 @@ class List:
# New style continuation, added in MediaWiki 1.21
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:
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`."""
if self.result_member not in data['query']:
self._iter = iter(range(0))
......@@ -118,35 +137,33 @@ class List:
else:
self._iter = iter(data['query'][self.result_member].values())
def __repr__(self):
return "<%s object '%s' for %s>" % (
self.__class__.__name__,
self.list_name,
self.site
)
def __repr__(self) -> str:
return f"<{self.__class__.__name__} object '{self.list_name}' for {self.site}>"
@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)
for key, value in kwargs.items():
if value is not None and value is not False:
yield _prefix + key, value
@staticmethod
def get_prefix(prefix, generator=False):
def get_prefix(prefix: str, generator: bool = False) -> str:
return ('g' if generator else '') + prefix
@staticmethod
def get_list(generator=False):
def get_list(generator: bool = False) -> Union[Type['GeneratorList'], Type['List']]:
return GeneratorList if generator else List
class NestedList(List):
def __init__(self, nested_param, *args, **kwargs):
super(NestedList, self).__init__(*args, **kwargs)
def __init__(self, nested_param: str, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
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])
......@@ -158,9 +175,17 @@ class GeneratorList(List):
this subclass turns the data into Page, Image or Category objects.
"""
def __init__(self, site, list_name, prefix, *args, **kwargs):
super(GeneratorList, self).__init__(site, list_name, prefix,
*args, **kwargs)
def __init__(
self,
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']
del self.args[self.prefix + 'limit']
......@@ -173,40 +198,64 @@ class GeneratorList(List):
self.page_class = mwclient.page.Page
def __next__(self):
info = super(GeneratorList, self).__next__()
def __next__(self) -> Union['mwclient.page.Page', 'mwclient.image.Image', 'Category']:
info = super().__next__()
if info['ns'] == 14:
return Category(self.site, '', info)
if info['ns'] == 6:
return mwclient.image.Image(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
# on uninitialized sites
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):
"""
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)
kwargs = {}
kwargs = {} # type: Dict[str, Any]
kwargs['gcmtitle'] = self.name
if namespace:
kwargs['gcmnamespace'] = namespace
GeneratorList.__init__(self, site, 'categorymembers', 'cm', **kwargs)
def __repr__(self):
return "<%s object '%s' for %s>" % (
self.__class__.__name__,
self.name.encode('utf-8'),
self.site
)
def members(self, prop='ids|title', namespace=None, sort='sortkey',
dir='asc', start=None, end=None, generator=True):
def __repr__(self) -> str:
return f"<{self.__class__.__name__} object '{self.name}' for {self.site}>"
def members(
self,
prop: str = 'ids|title',
namespace: Optional[Namespace] = None,
sort: str = 'sortkey',
dir: str = 'asc',
start: Optional[str] = None,
end: Optional[str] = None,
generator: bool = True
) -> 'List':
prefix = self.get_prefix('cm', generator)
kwargs = dict(self.generate_kwargs(prefix, prop=prop, namespace=namespace,
sort=sort, dir=dir, start=start, end=end,
......@@ -216,8 +265,15 @@ class Category(mwclient.page.Page, GeneratorList):
class PageList(GeneratorList):
def __init__(self, site, prefix=None, start=None, namespace=0, redirects='all',
end=None):
def __init__(
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
kwargs = {}
......@@ -228,36 +284,44 @@ class PageList(GeneratorList):
if end:
kwargs['gapto'] = end
super(PageList, self).__init__(site, 'allpages', 'ap',
gapnamespace=str(namespace),
gapfilterredir=redirects,
**kwargs)
super().__init__(site, 'allpages', 'ap', gapnamespace=str(namespace),
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)
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.
If self.namespace is not zero, use {namespace}:{name} as the
page name, otherwise guess the namespace from the name using
`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:
One of Category, Image or Page (default), according to namespace.
"""
if self.namespace != 0:
full_page_name = u"{namespace}:{name}".format(
namespace=self.site.namespaces[self.namespace],
name=name,
)
full_page_name = f"{self.site.namespaces[self.namespace]}:{name}" \
# type: Union[str, int, 'mwclient.page.Page']
namespace = self.namespace
else:
full_page_name = name
try:
if isinstance(name, str):
namespace = self.guess_namespace(name)
except AttributeError:
# raised when `namespace` doesn't have a `startswith` attribute
else:
namespace = 0
cls = {
......@@ -265,16 +329,16 @@ class PageList(GeneratorList):
6: mwclient.image.Image,
}.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
If name starts with any of the site's namespaces' names or
default_namespaces, use that. Else, return zero.
Args:
name (str): The pagename as a string (having `.startswith`)
name: The name of the page.
Returns:
The id of the guessed namespace or zero.
......@@ -282,26 +346,34 @@ class PageList(GeneratorList):
for ns in self.site.namespaces:
if ns == 0:
continue
namespace = '%s:' % self.site.namespaces[ns].replace(' ', '_')
namespace = f'{self.site.namespaces[ns].replace(" ", "_")}:'
if name.startswith(namespace):
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
class PageProperty(List):
def __init__(self, page, prop, prefix, *args, **kwargs):
super(PageProperty, self).__init__(page.site, prop, prefix,
titles=page.name,
*args, **kwargs)
def __init__(
self,
page: 'mwclient.page.Page',
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.generator = 'prop'
def set_iter(self, data):
def set_iter(self, data: Mapping[str, Any]) -> None:
for page in data['query']['pages'].values():
if page['title'] == self.page.name:
self._iter = iter(page.get(self.list_name, ()))
......@@ -311,16 +383,21 @@ class PageProperty(List):
class PagePropertyGenerator(GeneratorList):
def __init__(self, page, prop, prefix, *args, **kwargs):
super(PagePropertyGenerator, self).__init__(page.site, prop, prefix,
titles=page.name,
*args, **kwargs)
def __init__(
self,
page: 'mwclient.page.Page',
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
class RevisionsIterator(PageProperty):
def load_chunk(self):
def load_chunk(self) -> None:
if 'rvstartid' in self.args and 'rvstart' in self.args:
del self.args['rvstart']
return super(RevisionsIterator, self).load_chunk()
return super().load_chunk()
import time
from mwclient.util import parse_timestamp
import mwclient.listing
from typing import ( # noqa: F401
Optional, Mapping, Any, cast, Dict, Union, Tuple, Iterable, List, NoReturn
)
import mwclient.errors
import mwclient.listing
from mwclient._types import Namespace
from mwclient.util import parse_timestamp, handle_limit
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):
self.__dict__.update(name.__dict__)
return
self.site = site
self.name = name
self._textcache = {}
self._textcache = {} # type: Dict[int, str]
if not info:
if extra_properties:
prop = 'info|' + '|'.join(extra_properties.keys())
extra_props = []
extra_props = [] # type: List[Tuple[str, str]]
for extra_prop in extra_properties.values():
extra_props.extend(extra_prop)
else:
prop = 'info'
extra_props = ()
extra_props = []
if type(name) is int:
info = self.site.get('query', prop=prop, pageids=name,
......@@ -31,21 +65,15 @@ class Page:
info = self.site.get('query', prop=prop, titles=name,
inprop='protection', *extra_props)
info = next(iter(info['query']['pages'].values()))
info = cast(Mapping[str, Any], info)
self._info = info
if 'invalid' in info:
raise mwclient.errors.InvalidPageTitle(info.get('invalidreason'))
self.namespace = info.get('ns', 0)
self.name = info.get('title', '')
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.name = info.get('title', '') # type: str
self.touched = parse_timestamp(info.get('touched'))
self.revision = info.get('lastrevid', 0)
self.exists = 'missing' not in info
......@@ -61,10 +89,25 @@ class Page:
self.pagelanguage = info.get('pagelanguage', None)
self.restrictiontypes = info.get('restrictiontypes', None)
self.last_rev_time = None
self.edit_time = None
self.last_rev_time = None # type: Optional[time.struct_time]
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."""
info = self.site.get('query', prop='pageprops', titles=self.name, redirects='')
if 'redirects' in info['query']:
......@@ -75,7 +118,7 @@ class Page:
else:
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."""
target_page = self.redirects_to()
if target_page is None:
......@@ -83,21 +126,17 @@ class Page:
else:
return target_page
def __repr__(self):
return "<%s object '%s' for %s>" % (
self.__class__.__name__,
self.name.encode('utf-8'),
self.site
)
def __repr__(self) -> str:
return f"<{self.__class__.__name__} object '{self.name}' for {self.site}>"
@staticmethod
def strip_namespace(title):
def strip_namespace(title: str) -> str:
if title[0] == ':':
title = title[1:]
return title[title.find(':') + 1:]
@staticmethod
def normalize_title(title):
def normalize_title(title: str) -> str:
# TODO: Make site dependent
title = title.strip()
if title[0] == ':':
......@@ -106,7 +145,7 @@ class Page:
title = title.replace(' ', '_')
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
with the current page.
......@@ -121,10 +160,16 @@ class Page:
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)
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.
If the page does not exist, an empty string is returned. By
......@@ -134,9 +179,9 @@ class Page:
lives as long as the instance does.
Args:
section (int): Section number, to only get text from a single section.
expandtemplates (bool): Expand templates (default: `False`)
cache (bool): Use in-memory caching (default: `True`)
section: Section number, to only get text from a single section.
expandtemplates: Expand templates (default: `False`)
cache: Use in-memory caching (default: `True`)
"""
if not self.can('read'):
......@@ -150,7 +195,10 @@ class Page:
if cache and key in self._textcache:
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)
try:
rev = next(revs)
......@@ -173,28 +221,52 @@ class Page:
self._textcache[key] = 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."""
return self.edit(*args, **kwargs)
def edit(self, text, summary='', minor=False, bot=True, section=None, **kwargs):
return self.edit(*args, **kwargs) # type: ignore[arg-type]
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.
"""
return self._edit(summary, minor, bot, section, text=text, **kwargs)
def append(self, text, summary='', minor=False, bot=True, section=None,
**kwargs):
def append(
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.
"""
return self._edit(summary, minor, bot, section, appendtext=text, **kwargs)
def prepend(self, text, summary='', minor=False, bot=True, section=None,
**kwargs):
def prepend(
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.
"""
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:
raise mwclient.errors.AssertUserFailedError()
if self.site.blocked:
......@@ -202,9 +274,6 @@ class Page:
if not self.can('edit'):
raise mwclient.errors.ProtectedPageError(self)
if not self.site.writeapi:
raise mwclient.errors.NoWriteApi(self)
data = {}
if minor:
data['minor'] = '1'
......@@ -224,7 +293,7 @@ class Page:
if self.site.force_login:
data['assert'] = 'user'
def do_edit():
def do_edit() -> Dict[str, Any]:
result = self.site.post('edit', title=self.name, summary=summary,
token=self.get_token('edit'),
**data)
......@@ -240,14 +309,21 @@ class Page:
self.get_token('edit', force=True)
try:
result = do_edit()
except mwclient.errors.APIError as e:
self.handle_edit_error(e, summary)
except mwclient.errors.APIError as e2:
self.handle_edit_error(e2, summary)
else:
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
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
for cookie in self.site.connection.cookies:
......@@ -259,7 +335,7 @@ class Page:
self._textcache = {}
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':
raise mwclient.errors.EditError(self, summary, e.info)
elif e.code in {'protectedtitle', 'cantcreate', 'cantcreate-anon',
......@@ -273,7 +349,7 @@ class Page:
else:
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.
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
......@@ -283,8 +359,15 @@ class Page:
return
self.append('')
def move(self, new_title, reason='', move_talk=True, no_redirect=False,
move_subpages=False, ignore_warnings=False):
def move(
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.
If user account is an administrator, specify no_redirect as True to not
......@@ -293,13 +376,11 @@ class Page:
If user does not have permission to move page, an InsufficientPermission
exception is raised.
API doc: https://www.mediawiki.org/wiki/API:Move
"""
if not self.can('move'):
raise mwclient.errors.InsufficientPermission(self)
if not self.site.writeapi:
raise mwclient.errors.NoWriteApi(self)
data = {}
if move_talk:
data['movetalk'] = '1'
......@@ -309,23 +390,34 @@ class Page:
data['movesubpages'] = '1'
if ignore_warnings:
data['ignorewarnings'] = '1'
result = self.site.post('move', ('from', self.name), to=new_title,
token=self.get_token('move'), reason=reason, **data)
if 'redirectcreated' in result['move']:
self.redirect = True
else:
self.exists = False
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.
If user does not have permission to delete page, an InsufficientPermission
exception is raised.
API doc: https://www.mediawiki.org/wiki/API:Delete
"""
if not self.can('delete'):
raise mwclient.errors.InsufficientPermission(self)
if not self.site.writeapi:
raise mwclient.errors.NoWriteApi(self)
data = {}
if watch:
data['watch'] = '1'
......@@ -333,49 +425,64 @@ class Page:
data['unwatch'] = '1'
if oldimage:
data['oldimage'] = oldimage
result = self.site.post('delete', title=self.name,
token=self.get_token('delete'),
reason=reason, **data)
self.exists = False
return result['delete']
def purge(self):
def purge(self) -> None:
"""Purge server-side cache of page. This will re-render templates and other
dynamic content.
API doc: https://www.mediawiki.org/wiki/API:Purge
"""
self.site.post('purge', titles=self.name)
# def watch: requires 1.14
# Properties
def backlinks(self, namespace=None, filterredir='all', redirect=False,
limit=None, generator=True):
def backlinks(
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.
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)
kwargs = dict(mwclient.listing.List.generate_kwargs(
prefix, namespace=namespace, filterredir=filterredir,
))
if redirect:
kwargs['%sredirect' % prefix] = '1'
kwargs[f'{prefix}redirect'] = '1'
kwargs[prefix + 'title'] = self.name
return mwclient.listing.List.get_list(generator)(
self.site, 'backlinks', 'bl', limit=limit, return_values='title',
**kwargs
self.site, 'backlinks', 'bl', max_items=max_items,
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.
API doc: https://www.mediawiki.org/wiki/API:Categories
Args:
generator (bool): Return generator (Default: True)
show (str): Set to 'hidden' to only return hidden categories
generator: Return generator (Default: True)
show: Set to 'hidden' to only return hidden categories
or '!hidden' to only return non-hidden ones.
Returns:
......@@ -396,32 +503,43 @@ class Page:
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.
API doc: https://www.mediawiki.org/wiki/API:Embeddedin
Args:
namespace (int): Restricts search to a given namespace (Default: None)
filterredir (str): How to filter redirects, either 'all' (default),
namespace Restricts search to a given namespace (Default: None)
filterredir: How to filter redirects, either 'all' (default),
'redirects' or 'nonredirects'.
limit (int): Maximum amount of pages to return per request
generator (bool): Return generator (Default: True)
limit: The API request chunk size (deprecated)
generator: Return generator (Default: True)
max_items: The maximum number of pages to yield
api_chunk_size: The API request chunk size
Returns:
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)
kwargs = dict(mwclient.listing.List.generate_kwargs(prefix, namespace=namespace,
filterredir=filterredir))
kwargs[prefix + 'title'] = self.name
return mwclient.listing.List.get_list(generator)(
self.site, 'embeddedin', 'ei', limit=limit, return_values='title',
**kwargs
self.site, 'embeddedin', 'ei', max_items=max_items,
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.
API doc: https://www.mediawiki.org/wiki/API:Extlinks
......@@ -429,7 +547,9 @@ class Page:
"""
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.
API doc: https://www.mediawiki.org/wiki/API:Images
......@@ -441,7 +561,7 @@ class Page:
return mwclient.listing.PageProperty(self, 'images', '',
return_values='title')
def iwlinks(self):
def iwlinks(self) -> 'mwclient.listing.PageProperty':
"""List interwiki links from the current page.
API doc: https://www.mediawiki.org/wiki/API:Iwlinks
......@@ -450,7 +570,7 @@ class Page:
return mwclient.listing.PageProperty(self, 'iwlinks', 'iw',
return_values=('prefix', '*'))
def langlinks(self, **kwargs):
def langlinks(self, **kwargs: Any) -> 'mwclient.listing.PageProperty':
"""List interlanguage links from the current page.
API doc: https://www.mediawiki.org/wiki/API:Langlinks
......@@ -460,7 +580,12 @@ class Page:
return_values=('lang', '*'),
**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.
API doc: https://www.mediawiki.org/wiki/API:Links
......@@ -477,44 +602,61 @@ class Page:
return mwclient.listing.PageProperty(self, 'links', 'pl',
return_values='title', **kwargs)
def revisions(self, startid=None, endid=None, start=None, end=None,
dir='older', user=None, excludeuser=None, limit=50,
prop='ids|timestamp|flags|comment|user',
expandtemplates=False, section=None,
diffto=None, slots=None, uselang=None):
def revisions(
self,
startid: Optional[int] = None,
endid: Optional[int] = 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.
API doc: https://www.mediawiki.org/wiki/API:Revisions
Args:
startid (int): Revision ID to start listing from.
endid (int): Revision ID to stop listing at.
start (str): Timestamp to start listing from.
end (str): Timestamp to end listing at.
dir (str): Direction to list in: 'older' (default) or 'newer'.
user (str): Only list revisions made by this user.
excludeuser (str): Exclude revisions made by this user.
limit (int): The maximum number of revisions to return per request.
prop (str): Which properties to get for each revision,
startid: Revision ID to start listing from.
endid: Revision ID to stop listing at.
start: Timestamp to start listing from.
end: Timestamp to end listing at.
dir: Direction to list in: 'older' (default) or 'newer'.
user: Only list revisions made by this user.
excludeuser: Exclude revisions made by this user.
limit: The API request chunk size (deprecated).
prop: Which properties to get for each revision,
default: 'ids|timestamp|flags|comment|user'
expandtemplates (bool): Expand templates in rvprop=content output
section (int): Section number. If rvprop=content is set, only the contents
expandtemplates: Expand templates in rvprop=content output
section: Section number. If rvprop=content is set, only the contents
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.
slots (str): The content slot (Mediawiki >= 1.32) to retrieve content from.
uselang (str): Language to use for parsed edit comments and other localized
slots: The content slot (Mediawiki >= 1.32) to retrieve content from.
uselang: Language to use for parsed edit comments and other localized
messages.
max_items: The maximum number of revisions to yield.
api_chunk_size: The API request chunk size (as a number of revisions).
Returns:
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(
'rv', startid=startid, endid=endid, start=start, end=end, user=user,
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
del kwargs['rvslots']
......@@ -526,10 +668,14 @@ class Page:
if section is not None:
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)
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.
API doc: https://www.mediawiki.org/wiki/API:Templates
......
import time
import logging
import time
from typing import Callable, Optional, Any
from mwclient.errors import MaximumRetriesExceeded
log = logging.getLogger(__name__)
......@@ -17,24 +19,30 @@ class Sleepers:
using the `make` method.
>>> sleeper = sleepers.make()
Args:
max_retries (int): The maximum number of retries to perform.
retry_timeout (int): The time to sleep for each past retry.
callback (Callable[[int, Any], None]): A callable to be called on each retry.
max_retries: The maximum number of retries to perform.
retry_timeout: The time to sleep for each past retry.
callback: A callable to be called on each retry.
Attributes:
max_retries (int): The maximum number of retries to perform.
retry_timeout (int): The time to sleep for each past retry.
callback (callable): A callable to be called on each retry.
max_retries: The maximum number of retries to perform.
retry_timeout: The time to sleep for each past 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.retry_timeout = retry_timeout
self.callback = callback
def make(self, args=None):
def make(self, args: Optional[Any] = None) -> 'Sleeper':
"""
Creates a new `Sleeper` object.
Args:
args (Any): Arguments to be passed to the `callback` callable.
args: Arguments to be passed to the `callback` callable.
Returns:
Sleeper: A `Sleeper` object.
"""
......@@ -48,25 +56,32 @@ class Sleeper:
and a `MaximumRetriesExceeded` is raised. The sleeper object should be discarded
once the operation is successful.
Args:
args (Any): Arguments to be passed to the `callback` callable.
max_retries (int): The maximum number of retries to perform.
retry_timeout (int): The time to sleep for each past retry.
callback (callable, None]): A callable to be called on each retry.
args: Arguments to be passed to the `callback` callable.
max_retries: The maximum number of retries to perform.
retry_timeout: The time to sleep for each past retry.
callback: A callable to be called on each retry.
Attributes:
args (Any): Arguments to be passed to the `callback` callable.
retries (int): The number of retries that have been performed.
max_retries (int): The maximum number of retries to perform.
retry_timeout (int): The time to sleep for each past retry.
callback (callable): A callable to be called on each retry.
args: Arguments to be passed to the `callback` callable.
retries: The number of retries that have been performed.
max_retries: The maximum number of retries to perform.
retry_timeout: The time to sleep for each past 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.retries = 0
self.max_retries = max_retries
self.retry_timeout = retry_timeout
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
with the number of retries.
......
import time
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.
Args:
t (str): A string containing a timestamp.
t: A string containing a timestamp.
Returns:
time.struct_time: A timestamp.
......@@ -16,9 +18,38 @@ def parse_timestamp(t):
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:
data = stream.read(chunk_size)
if not data:
break
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]
requires = ["setuptools>=40.6.0", "wheel"]
build-backend = "setuptools.build_meta"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
addopts = "--cov mwclient test"
[tool.hatch.version]
path = "mwclient/client.py"
[tool.hatch.build.targets.sdist]
exclude = [
"/.flake8",
"/.gitattributes",
"/.github",
"/.readthedocs.yaml",
]
[tool.bumpversion]
current_version = "0.10.1"
current_version = "0.11.0"
commit = true
tag = true
[[tool.bumpversion.files]]
filename = "setup.py"
search = "version='{current_version}'"
replace = "version='{new_version}'"
[[tool.bumpversion.files]]
filename = "mwclient/client.py"
search = "__version__ = '{current_version}'"
replace = "__version__ = '{new_version}'"
[[tool.bumpversion.files]]
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
from io import BytesIO
import logging
import sys
import time
import unittest
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 requests
import responses
from requests_oauthlib import OAuth1
import mwclient
if __name__ == "__main__":
print()
print("Note: Running in stand-alone mode. Consult the README")
......@@ -26,24 +26,61 @@ logging.basicConfig(level=logging.DEBUG)
class TestCase(unittest.TestCase):
def metaResponse(self, **kwargs):
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}}}'
tpl = tpl % {'version': kwargs.get('version', '1.24wmf17'),
'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"]))
}
res = json.loads(tpl)
if kwargs.get('writeapi', True):
res['query']['general']['writeapi'] = ''
def metaResponse(self, version='1.24wmf17', rights=None):
if rights is None:
rights = [
"createaccount", "read", "edit", "createpage", "createtalk",
"editmyusercss", "editmyuserjs", "viewmywatchlist",
"editmywatchlist", "viewmyprivateinfo", "editmyprivateinfo",
"editmyoptions", "centralauth-merge", "abusefilter-view",
"abusefilter-log", "translate", "vipsscaler-test", "upload"
]
# @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):
return json.dumps(self.metaResponse(**kwargs))
def httpShouldReturn(self, body=None, callback=None, scheme='https', host='test.wikipedia.org', path='/w/',
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
if body is None:
responses.add_callback(mock, url, callback=callback)
......@@ -90,7 +127,15 @@ class TestClient(TestCase):
def testVersion(self):
# 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__
......@@ -120,8 +165,8 @@ class TestClient(TestCase):
site = mwclient.Site('test.wikipedia.org')
assert len(responses.calls) == 2
assert 'retry-after' in responses.calls[0].response.headers
assert 'retry-after' not in responses.calls[1].response.headers
assert 'retry-after' in responses.calls[0].response.headers # type: ignore
assert 'retry-after' not in responses.calls[1].response.headers # type: ignore
@responses.activate
def test_http_error(self):
......@@ -169,6 +214,7 @@ class TestClient(TestCase):
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 'meta=siteinfo%7Cuserinfo' in responses.calls[0].request.url
......@@ -196,7 +242,8 @@ class TestClient(TestCase):
self.httpShouldReturn(self.metaResponseAsJson())
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
def test_oauth(self):
......@@ -353,9 +400,9 @@ class TestClient(TestCase):
}
}), 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
def test_smw_response_v2(self):
......@@ -395,9 +442,9 @@ class TestClient(TestCase):
}
}), 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
def test_repr(self):
......@@ -469,6 +516,18 @@ class TestClient(TestCase):
site.raw_api("query", "GET", retry_on_error=False)
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):
@mock.patch('mwclient.client.Site.site_init')
......@@ -553,7 +612,7 @@ class TestLogin(TestCase):
# this would be done by site_init usually, but we're mocking it
site.version = (1, 28, 0)
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
......@@ -602,7 +661,7 @@ class TestLogin(TestCase):
'clientlogin', 'POST',
username='myusername',
password='mypassword',
loginreturnurl='%s://%s' % (site.scheme, site.host),
loginreturnurl=f'{site.scheme}://{site.host}',
logintoken=login_token
)
......@@ -629,7 +688,7 @@ class TestLogin(TestCase):
# this would be done by site_init usually, but we're mocking it
site.version = (1, 28, 0)
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
......@@ -684,6 +743,47 @@ class TestClientApiMethods(TestCase):
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):
def setUp(self):
......@@ -734,7 +834,7 @@ class TestClientUploadArgs(TestCase):
# Test that methods are called, and arguments sent as expected
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
data = args[1]
......@@ -750,19 +850,46 @@ class TestClientUploadArgs(TestCase):
self.configure()
with pytest.raises(TypeError):
self.site.upload(file=StringIO('test'))
self.site.upload(file=BytesIO(b'test'))
def test_upload_ambigitious_args(self):
self.configure()
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):
self.configure(rights=['read'])
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):
......@@ -1420,6 +1547,74 @@ class TestUser(TestCase):
assert real_call_kwargs == mock_call_kwargs
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__':
unittest.main()
import unittest
import pytest
import logging
import requests
import responses
import json
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
......@@ -22,7 +20,54 @@ class TestList(unittest.TestCase):
def setUp(self):
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:
ns = [0, 0, 0]
mock_site.get.side_effect = [
......@@ -64,19 +109,65 @@ class TestList(unittest.TestCase):
# Test that the list fetches all three responses
# and yields dicts when return_values not set
lst = List(mock_site, 'allpages', 'ap', limit=2)
self.setupDummyResponses(mock_site, 'allpages')
lst = List(mock_site, 'allpages', 'ap', api_chunk_size=2)
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]
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_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')
def test_list_with_str_return_value(self, mock_site):
# Test that the List yields strings when return_values is string
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]
assert len(vals) == 3
......@@ -88,18 +179,116 @@ class TestList(unittest.TestCase):
lst = List(mock_site, 'allpages', 'ap', limit=2,
return_values=('title', 'ns'))
self.setupDummyResponses(mock_site, 'allpages')
self.setupDummyResponsesTwo(mock_site, 'allpages')
vals = [x for x in lst]
assert len(vals) == 3
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')
def test_generator_list(self, mock_site):
# Test that the GeneratorList yields Page objects
mock_site.api_limit = 500
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]
assert len(vals) == 3
......@@ -107,5 +296,172 @@ class TestList(unittest.TestCase):
assert type(vals[1]) == mwclient.image.Image
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__':
unittest.main()
import time
import unittest
import unittest.mock as mock
import pytest
import logging
import requests
import responses
import json
import mwclient
from mwclient.errors import APIError, AssertUserFailedError, ProtectedPageError, \
InvalidPageTitle
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__":
print()
......@@ -218,6 +214,123 @@ class TestPage(unittest.TestCase):
with pytest.raises(mwclient.errors.EditError):
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):
......@@ -270,7 +383,7 @@ class TestPageApiArgs(unittest.TestCase):
def test_get_page_text_cached(self):
# 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()
# When cache is hit, revisions is not, so call_count should be 1
......@@ -378,10 +491,10 @@ class TestPageApiArgs(unittest.TestCase):
'gcllimit': repr(self.page.site.api_limit),
} == args
assert set([c.name for c in cats]) == set([
assert {c.name for c in cats} == {
'Category:1879 births',
'Category:1955 deaths',
])
}
if __name__ == '__main__':
......
[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]
python =
......@@ -8,21 +9,32 @@ python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311, flake
3.11: py311
3.12: py312
3.13: py313
3.13: py313, flake, integration, mypy
3.14: py314
[testenv]
deps =
pytest
pytest-cov
responses
setuptools
mock
commands = py.test -v --cov mwclient test
extras = testing
commands = pytest -v --cov mwclient test
[testenv:flake]
deps =
flake8
commands =
flake8 mwclient
[testenv:integration]
deps =
pytest
commands = pytest test/integration.py -v
[testenv:mypy]
deps =
mypy
pytest
responses
types-requests
types-setuptools
commands =
mypy