Repository: mwclient/mwclient

Issue:
Site.blocks() only returns a single block
https://en.wikipedia.org/wiki/Special:Log?type=block&user=&page=MariaJaydHicky&wpdate=&tagfilter=&subtype= shows that User:MariaJaydHicky has had multiple blocks.  But, the following code:

```
#!/usr/bin/env python                                                                                                                                     

from mwclient import Site
from pprint import pprint

site = Site('en.wikipedia.org')
blocks = site.blocks(users=['MariaJaydHicky'])

for b in blocks:
    pprint(b)
```
only returns a single block, and not even the most recent one:

```
./blocks.py
OrderedDict([('id', 3697119),
             ('user', 'MariaJaydHicky'),
             ('by', 'Courcelles'),
             ('timestamp',
              time.struct_time(tm_year=2011, tm_mon=11, tm_mday=9, tm_hour=22, tm_min=1, tm_sec=6, tm_wday=2, tm_yday=313, tm_isdst=-1)),
             ('expiry', 'infinity'),
             ('reason',
              'revoking talk page access because of attack on various '
              'individuals and groups'),
             ('nocreate', ''),
             ('autoblock', '')])
```



I'm running:
mwclient==0.10.0
Python 3.5.3




Discussion:

Patch:
diff --git a/examples/basic_edit.py b/examples/basic_edit.py
index 43c4b00..a486047 100644
--- a/examples/basic_edit.py
+++ b/examples/basic_edit.py
@@ -36,7 +36,7 @@ text1 = u"""== [[Test page]] ==
 This is a [[test]] page generated by [http://mwclient.sourceforge.org/ mwclient].
 This test is done using the [[w:mw:API]]."""
 comment1 = 'Test page1'
-page.save(text1, comment1)
+page.edit(text1, comment1)
 
 rev = page.revisions(limit=1, prop='timestamp|comment|content').next()
 assert rev['comment'] == comment1, rev
diff --git a/mwclient/client.py b/mwclient/client.py
index c9dfb95..f0c8f1b 100644
--- a/mwclient/client.py
+++ b/mwclient/client.py
@@ -27,20 +27,65 @@ USER_AGENT = 'mwclient/{} ({})'.format(__version__,
 class Site(object):
     """A MediaWiki site identified by its hostname.
 
-        >>> import mwclient
-        >>> site = mwclient.Site('en.wikipedia.org')
-
-    Do not include the leading "http://".
-
-    Mwclient assumes that the script path (where index.php and api.php are located)
-    is '/w/'. If the site uses a different script path, you must specify this
-    (path must end in a '/').
-
     Examples:
-
-        >>> site = mwclient.Site('vim.wikia.com', path='/')
-        >>> site = mwclient.Site('sourceforge.net', path='/apps/mediawiki/mwclient/')
-
+        >>> import mwclient
+        >>> wikipedia_site = mwclient.Site('en.wikipedia.org')
+        >>> 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>`_
+            for more information.
+        max_lag (int): 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
+            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[str, str], 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}`.
+        reqs (Dict[str, Any]): 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
+            to the session.
+        custom_headers (Dict[str, str]): 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
+            most cases. Defaults to `https`.
+
+    Raises:
+        RuntimeError: The authentication passed to the `httpauth` parameter is invalid.
+            You must pass either a tuple or a :class:`requests.auth.AuthBase` object.
+        errors.OAuthAuthorizationError: The OAuth authorization is invalid.
+        errors.LoginError: Login failed, the reason can be obtained from e.code and e.info
+            (where e is the exception object) and will be one of the API:Login errors. The
+            most common error code is "Failed", indicating a wrong username or password.
     """
     api_limit = 500
 
@@ -70,6 +115,7 @@ class Site(object):
         elif httpauth is None or isinstance(httpauth, (AuthBase,)):
             auth = httpauth
         else:
+            # FIXME: Raise a specific exception instead of a generic RuntimeError.
             raise RuntimeError('Authentication is not a tuple or an instance of AuthBase')
 
         self.sleepers = Sleepers(max_retries, retry_timeout, wait_callback)
@@ -132,6 +178,9 @@ class Site(object):
                     raise
 
     def site_init(self):
+        """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."""
 
         if self.initialized:
             info = self.get('query', meta='userinfo', uiprop='groups|rights')
@@ -171,10 +220,15 @@ class Site(object):
         """Return a version tuple from a MediaWiki Generator string.
 
         Example:
-            "MediaWiki 1.5.1" → (1, 5, 1)
+            >>> Site.version_tuple_from_generator("MediaWiki 1.5.1")
+            (1, 5, 1)
 
         Args:
-            prefix (str): The expected prefix of the string
+            string (str): The MediaWiki Generator string.
+            prefix (str): The expected prefix of the string.
+
+        Returns:
+            A tuple containing the individual elements of the given version number.
         """
         if not string.startswith(prefix):
             raise errors.MediaWikiVersionError('Unknown generator {}'.format(string))
@@ -223,6 +277,9 @@ class Site(object):
         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.
+
         Returns:
             The raw response from the API call, as a dictionary.
         """
@@ -234,6 +291,9 @@ class Site(object):
         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.
+
         Returns:
             The raw response from the API call, as a dictionary.
         """
@@ -244,6 +304,10 @@ class Site(object):
 
         All arguments will be passed on.
 
+        Args:
+            action (str): The MediaWiki API action to be performed.
+            http_method (str): The HTTP method to use.
+
         Example:
             To get coordinates from the GeoData MediaWiki extension at English Wikipedia:
 
@@ -251,9 +315,9 @@ class Site(object):
             >>> result = site.api('query', prop='coordinates', titles='Oslo|Copenhagen')
             >>> for page in result['query']['pages'].values():
             ...     if 'coordinates' in page:
-            ...         print '{} {} {}'.format(page['title'],
+            ...         print('{} {} {}'.format(page['title'],
             ...             page['coordinates'][0]['lat'],
-            ...             page['coordinates'][0]['lon'])
+            ...             page['coordinates'][0]['lon']))
             Oslo 59.95 10.75
             Copenhagen 55.6761 12.5683
 
@@ -284,6 +348,20 @@ class Site(object):
                 return info
 
     def handle_api_result(self, info, kwargs=None, sleeper=None):
+        """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
+                :class:`errors.APIError`.
+            sleeper (sleep.Sleeper): A :class:`~sleep.Sleeper` instance to use when
+                sleeping.
+
+        Returns:
+            `False` if the given API response contains an exception, else `True`.
+        """
+
         if sleeper is None:
             sleeper = self.sleepers.make()
 
@@ -344,7 +422,7 @@ class Site(object):
         """
         Perform a generic request and return the raw text.
 
-        In the event of a network problem, or a HTTP response with status code 5XX,
+        In the event of a network problem, or an HTTP response with status code 5XX,
         we'll wait and retry the configured number of times before giving up
         if `retry_on_error` is True.
 
@@ -361,6 +439,15 @@ class Site(object):
 
         Returns:
             The raw text response.
+
+        Raises:
+            errors.MaximumRetriesExceeded: The API request failed and the maximum number
+                of retries was exceeded.
+            requests.exceptions.HTTPError: Received an invalid HTTP response, or a status
+                code in the 4xx range.
+            requests.exceptions.ConnectionError: Encountered an unexpected error while
+                performing the API request.
+            requests.exceptions.Timeout: The API request timed out.
         """
         headers = {}
         if self.compress:
@@ -420,12 +507,31 @@ class Site(object):
                 log.warning('Connection error. Retrying in a moment.')
                 sleeper.sleep()
 
-    def raw_api(self, action, http_method='POST', *args, **kwargs):
-        """Send a call to the API."""
-        try:
-            retry_on_error = kwargs.pop('retry_on_error')
-        except KeyError:
-            retry_on_error = True
+    def raw_api(self, action, http_method='POST', retry_on_error=True, *args, **kwargs):
+        """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.
+
+        Returns:
+            The API response.
+
+        Raises:
+            errors.APIDisabledError: The MediaWiki API is disabled for this instance.
+            errors.InvalidResponse: The API response could not be decoded from JSON.
+            errors.MaximumRetriesExceeded: The API request failed and the maximum number
+                of retries was exceeded.
+            requests.exceptions.HTTPError: Received an invalid HTTP response, or a status
+                code in the 4xx range.
+            requests.exceptions.ConnectionError: Encountered an unexpected error while
+                performing the API request.
+            requests.exceptions.Timeout: The API request timed out.
+        """
         kwargs['action'] = action
         kwargs['format'] = 'json'
         data = self._query_string(*args, **kwargs)
@@ -440,16 +546,62 @@ class Site(object):
             raise errors.InvalidResponse(res)
 
     def raw_index(self, action, http_method='POST', *args, **kwargs):
-        """Sends a call to index.php rather than the API."""
+        """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.
+
+        Returns:
+            The API response.
+
+        Raises:
+            errors.MaximumRetriesExceeded: The API request failed and the maximum number
+                of retries was exceeded.
+            requests.exceptions.HTTPError: Received an invalid HTTP response, or a status
+                code in the 4xx range.
+            requests.exceptions.ConnectionError: Encountered an unexpected error while
+                performing the API request.
+            requests.exceptions.Timeout: The API request timed out.
+        """
         kwargs['action'] = action
         kwargs['maxlag'] = self.max_lag
         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):
+        """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`.
+
+        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.
+
+        Raises:
+            errors.MediaWikiVersionError: The current wiki is below the required version
+                and `raise_error=True`.
+            RuntimeError: It `raise_error` is `None` and the `version` attribute is unset
+                This is usually done automatically on construction of the :class:`Site`,
+                unless `do_init=False` is passed to the constructor. After instantiation,
+                the :meth:`~Site.site_init` functon can be used to retrieve and set the
+                `version`.
+            NotImplementedError: If the `revision` argument was passed. The logic for this
+                is currently unimplemented.
+        """
         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))
 
         if revision is None:
@@ -647,7 +799,21 @@ class Site(object):
                                             login['clientlogin'].get('message'))
 
     def get_token(self, type, force=False, title=None):
+        """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.
+
+        Returns:
+            A MediaWiki token of the requested `type`.
+
+        Raises:
+            errors.APIError: A token of the given type could not be retrieved.
+        """
         if self.version is None or self.version[:2] >= (1, 24):
             # The 'csrf' (cross-site request forgery) token introduced in 1.24 replaces
             # the majority of older tokens, like edittoken and movetoken.
@@ -661,8 +827,8 @@ class Site(object):
 
             if self.version is None or self.version[:2] >= (1, 24):
                 # We use raw_api() rather than api() because api() is adding "userinfo"
-                # to the query and this raises an readapideniederror if the wiki is read
-                # protected and we're trying to fetch a login token.
+                # to the query and this raises a readapideniederror if the wiki is read
+                # protected, and we're trying to fetch a login token.
                 info = self.raw_api('query', 'GET', meta='tokens', type=type)
 
                 self.handle_api_result(info)
@@ -861,6 +1027,23 @@ class Site(object):
 
     def parse(self, text=None, title=None, page=None, prop=None,
               redirects=False, mobileformat=False):
+        """Parses the given content and returns parser output.
+
+        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
+                and title.
+            prop (str): 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.
+                Defaults to `False`.
+            mobileformat (bool): Return parse output in a format suitable for mobile
+                devices. Defaults to `False`.
+
+        Returns:
+            The parse output as generated by MediaWiki.
+        """
         kwargs = {}
         if text is not None:
             kwargs['text'] = text
@@ -953,6 +1136,8 @@ class Site(object):
                prop='id|user|by|timestamp|expiry|reason|flags'):
         """Retrieve blocks as a generator.
 
+        API doc: https://www.mediawiki.org/wiki/API:Blocks
+
         Returns:
             mwclient.listings.List: Generator yielding dicts, each dict containing:
                 - user: The username or IP address of the user
@@ -966,6 +1151,11 @@ class Site(object):
                 - nocreate: key is present (empty string) if the user's ability to create
                     accounts has been disabled.
 
+        See Also:
+            When using the ``users`` filter to search for blocked users, only one block
+            per given user will be returned. If you want to retrieve the entire block log
+            for a specific user, you can use the :meth:`Site.logevents` method with
+            ``type=block`` and ``title='User:JohnDoe'``.
         """
 
         # TODO: Fix. Fix what?
@@ -1059,7 +1249,7 @@ class Site(object):
         Example: Get revision text for two revisions:
 
             >>> for revision in site.revisions([689697696, 689816909], prop='content'):
-            ...     print revision['*']
+            ...     print(revision['*'])
 
         Args:
             revids (list): A list of (max 50) revisions.
@@ -1157,12 +1347,18 @@ class Site(object):
         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`.
         """
 
         kwargs = {}
         if title is not None:
             kwargs['title'] = title
         if generatexml:
+            # FIXME: Deprecated and replaced by `prop=parsetree`.
             kwargs['generatexml'] = '1'
 
         result = self.post('expandtemplates', text=text, **kwargs)
@@ -1178,6 +1374,9 @@ class Site(object):
 
         API doc: https://semantic-mediawiki.org/wiki/Ask_API
 
+        Args:
+            query (str): The SMW query to be executed.
+
         Returns:
             Generator for retrieving all search results, with each answer as a dictionary.
             If the query is invalid, an APIError is raised. A valid query with zero
diff --git a/mwclient/page.py b/mwclient/page.py
index 1475b7c..8917860 100644
--- a/mwclient/page.py
+++ b/mwclient/page.py
@@ -53,7 +53,7 @@ class Page(object):
         self.exists = 'missing' not in info
         self.length = info.get('length')
         self.protection = {
-            i['type']: (i['level'], i['expiry'])
+            i['type']: (i['level'], i.get('expiry'))
             for i in info.get('protection', ())
             if i
         }


Test patch:
diff --git a/test/test_page.py b/test/test_page.py
index 85f8174..d24910e 100644
--- a/test/test_page.py
+++ b/test/test_page.py
@@ -165,6 +165,12 @@ class TestPage(unittest.TestCase):
         assert page.can('edit') is True  # User has 'autoconfirmed'  right
         assert page.can('move') is True  # User has 'sysop' right
 
+        # check an unusual case: no 'expiry' key, see
+        # https://github.com/mwclient/mwclient/issues/290
+        del mock_site.get.return_value['query']['pages']['728']['protection'][0]['expiry']
+        page = Page(mock_site, title)
+        assert page.protection == {'edit': ('autoconfirmed', None), 'move': ('sysop', 'infinity')}
+
     @mock.patch('mwclient.client.Site')
     def test_redirect(self, mock_site):
         # Check that page.redirect is set correctly