Note: don't deploy until January 14.tags/v0.2
| @@ -36,13 +36,13 @@ This module contains all exceptions used by EarwigBot:: | |||||
| | +-- SQLError | | +-- SQLError | ||||
| +-- NoServiceError | +-- NoServiceError | ||||
| +-- LoginError | +-- LoginError | ||||
| +-- PermissionsError | |||||
| +-- NamespaceNotFoundError | +-- NamespaceNotFoundError | ||||
| +-- PageNotFoundError | +-- PageNotFoundError | ||||
| +-- InvalidPageError | +-- InvalidPageError | ||||
| +-- RedirectError | +-- RedirectError | ||||
| +-- UserNotFoundError | +-- UserNotFoundError | ||||
| +-- EditError | +-- EditError | ||||
| | +-- PermissionsError | |||||
| | +-- EditConflictError | | +-- EditConflictError | ||||
| | +-- NoContentError | | +-- NoContentError | ||||
| | +-- ContentTooBigError | | +-- ContentTooBigError | ||||
| @@ -120,6 +120,19 @@ class LoginError(WikiToolsetError): | |||||
| Raised by :py:meth:`Site._login <earwigbot.wiki.site.Site._login>`. | Raised by :py:meth:`Site._login <earwigbot.wiki.site.Site._login>`. | ||||
| """ | """ | ||||
| class PermissionsError(WikiToolsetError): | |||||
| """A permissions error ocurred. | |||||
| We tried to do something we don't have permission to, like trying to delete | |||||
| a page as a non-admin, or trying to edit a page without login information | |||||
| and AssertEdit enabled. This will also be raised if we have been blocked | |||||
| from editing. | |||||
| Raised by :py:meth:`Page.edit <earwigbot.wiki.page.Page.edit>`, | |||||
| :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`, and | |||||
| other API methods depending on settings. | |||||
| """ | |||||
| class NamespaceNotFoundError(WikiToolsetError): | class NamespaceNotFoundError(WikiToolsetError): | ||||
| """A requested namespace name or namespace ID does not exist. | """A requested namespace name or namespace ID does not exist. | ||||
| @@ -164,18 +177,6 @@ class EditError(WikiToolsetError): | |||||
| :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. | :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. | ||||
| """ | """ | ||||
| class PermissionsError(EditError): | |||||
| """A permissions error ocurred while editing. | |||||
| We tried to do something we don't have permission to, like trying to delete | |||||
| a page as a non-admin, or trying to edit a page without login information | |||||
| and AssertEdit enabled. This will also be raised if we have been blocked | |||||
| from editing. | |||||
| Raised by :py:meth:`Page.edit <earwigbot.wiki.page.Page.edit>` and | |||||
| :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. | |||||
| """ | |||||
| class EditConflictError(EditError): | class EditConflictError(EditError): | ||||
| """We gotten an edit conflict or a (rarer) delete/recreate conflict. | """We gotten an edit conflict or a (rarer) delete/recreate conflict. | ||||
| @@ -308,6 +308,7 @@ class Page(CopyvioMixIn): | |||||
| section, captcha_id, captcha_word) | section, captcha_id, captcha_word) | ||||
| else: # Make sure we have the right token: | else: # Make sure we have the right token: | ||||
| params["token"] = self._token | params["token"] = self._token | ||||
| self._token = None # Token now invalid | |||||
| # Try the API query, catching most errors with our handler: | # Try the API query, catching most errors with our handler: | ||||
| try: | try: | ||||
| @@ -324,13 +325,8 @@ class Page(CopyvioMixIn): | |||||
| self._exists = self.PAGE_UNKNOWN | self._exists = self.PAGE_UNKNOWN | ||||
| return | return | ||||
| # If we're here, then the edit failed. If it's because of AssertEdit, | |||||
| # handle that. Otherwise, die - something odd is going on: | |||||
| try: | |||||
| assertion = result["edit"]["assert"] | |||||
| except KeyError: | |||||
| raise exceptions.EditError(result["edit"]) | |||||
| self._handle_assert_edit(assertion, params, tries) | |||||
| # Otherwise, there was some kind of problem. Throw an exception: | |||||
| raise exceptions.EditError(result["edit"]) | |||||
| def _build_edit_params(self, text, summary, minor, bot, force, section, | def _build_edit_params(self, text, summary, minor, bot, force, section, | ||||
| captcha_id, captcha_word): | captcha_id, captcha_word): | ||||
| @@ -371,95 +367,27 @@ class Page(CopyvioMixIn): | |||||
| is protected), or we'll try to fix it (for example, if we can't edit | is protected), or we'll try to fix it (for example, if we can't edit | ||||
| due to being logged out, we'll try to log in). | due to being logged out, we'll try to log in). | ||||
| """ | """ | ||||
| if error.code in ["noedit", "cantcreate", "protectedtitle", | |||||
| "noimageredirect"]: | |||||
| perms = ["noedit", "noedit-anon", "cantcreate", "cantcreate-anon", | |||||
| "protectedtitle", "noimageredirect", "noimageredirect-anon", | |||||
| "blocked"] | |||||
| if error.code in perms: | |||||
| raise exceptions.PermissionsError(error.info) | raise exceptions.PermissionsError(error.info) | ||||
| elif error.code in ["noedit-anon", "cantcreate-anon", | |||||
| "noimageredirect-anon"]: | |||||
| if not all(self.site._login_info): | |||||
| # Insufficient login info: | |||||
| raise exceptions.PermissionsError(error.info) | |||||
| if tries == 0: | |||||
| # We have login info; try to login: | |||||
| self.site._login(self.site._login_info) | |||||
| self._token = None # Need a new token; old one is invalid now | |||||
| return self._edit(params=params, tries=1) | |||||
| else: | |||||
| # We already tried to log in and failed! | |||||
| e = "Although we should be logged in, we are not. This may be a cookie problem or an odd bug." | |||||
| raise exceptions.LoginError(e) | |||||
| elif error.code in ["editconflict", "pagedeleted", "articleexists"]: | elif error.code in ["editconflict", "pagedeleted", "articleexists"]: | ||||
| # These attributes are now invalidated: | # These attributes are now invalidated: | ||||
| self._content = None | self._content = None | ||||
| self._basetimestamp = None | self._basetimestamp = None | ||||
| self._exists = self.PAGE_UNKNOWN | self._exists = self.PAGE_UNKNOWN | ||||
| raise exceptions.EditConflictError(error.info) | raise exceptions.EditConflictError(error.info) | ||||
| elif error.code in ["emptypage", "emptynewsection"]: | elif error.code in ["emptypage", "emptynewsection"]: | ||||
| raise exceptions.NoContentError(error.info) | raise exceptions.NoContentError(error.info) | ||||
| elif error.code == "blocked": | |||||
| if tries > 0 or not all(self.site._login_info): | |||||
| raise exceptions.PermissionsError(error.info) | |||||
| else: | |||||
| # Perhaps we are blocked from being logged-out? Try to log in: | |||||
| self.site._login(self.site._login_info) | |||||
| self._token = None # Need a new token; old one is invalid now | |||||
| return self._edit(params=params, tries=1) | |||||
| elif error.code == "contenttoobig": | elif error.code == "contenttoobig": | ||||
| raise exceptions.ContentTooBigError(error.info) | raise exceptions.ContentTooBigError(error.info) | ||||
| elif error.code == "spamdetected": | elif error.code == "spamdetected": | ||||
| raise exceptions.SpamDetectedError(error.info) | raise exceptions.SpamDetectedError(error.info) | ||||
| elif error.code == "filtered": | elif error.code == "filtered": | ||||
| raise exceptions.FilteredError(error.info) | raise exceptions.FilteredError(error.info) | ||||
| raise exceptions.EditError(": ".join((error.code, error.info))) | raise exceptions.EditError(": ".join((error.code, error.info))) | ||||
| def _handle_assert_edit(self, assertion, params, tries): | |||||
| """If we can't edit due to a failed AssertEdit assertion, handle that. | |||||
| If the assertion was 'user' and we have valid login information, try to | |||||
| log in. Otherwise, raise PermissionsError with details. | |||||
| """ | |||||
| if assertion == "user": | |||||
| if not all(self.site._login_info): | |||||
| # Insufficient login info: | |||||
| e = "AssertEdit: user assertion failed, and no login info was provided." | |||||
| raise exceptions.PermissionsError(e) | |||||
| if tries == 0: | |||||
| # We have login info; try to login: | |||||
| self.site._login(self.site._login_info) | |||||
| self._token = None # Need a new token; old one is invalid now | |||||
| return self._edit(params=params, tries=1) | |||||
| else: | |||||
| # We already tried to log in and failed! | |||||
| e = "Although we should be logged in, we are not. This may be a cookie problem or an odd bug." | |||||
| raise exceptions.LoginError(e) | |||||
| elif assertion == "bot": | |||||
| if not all(self.site._login_info): | |||||
| # Insufficient login info: | |||||
| e = "AssertEdit: bot assertion failed, and no login info was provided." | |||||
| raise exceptions.PermissionsError(e) | |||||
| if tries == 0: | |||||
| # Try to log in if we got logged out: | |||||
| self.site._login(self.site._login_info) | |||||
| self._token = None # Need a new token; old one is invalid now | |||||
| return self._edit(params=params, tries=1) | |||||
| else: | |||||
| # We already tried to log in, so we don't have a bot flag: | |||||
| e = "AssertEdit: bot assertion failed: we don't have a bot flag!" | |||||
| raise exceptions.PermissionsError(e) | |||||
| # Unknown assertion, maybe "true", "false", or "exists": | |||||
| e = "AssertEdit: assertion '{0}' failed.".format(assertion) | |||||
| raise exceptions.PermissionsError(e) | |||||
| @property | @property | ||||
| def site(self): | def site(self): | ||||
| """The page's corresponding Site object.""" | """The page's corresponding Site object.""" | ||||
| @@ -209,11 +209,13 @@ class Site(object): | |||||
| args.append(key + "=" + val) | args.append(key + "=" + val) | ||||
| return "&".join(args) | return "&".join(args) | ||||
| def _api_query(self, params, tries=0, wait=5, ignore_maxlag=False): | |||||
| def _api_query(self, params, tries=0, wait=5, ignore_maxlag=False, | |||||
| ae_retry=True): | |||||
| """Do an API query with *params* as a dict of parameters. | """Do an API query with *params* as a dict of parameters. | ||||
| See the documentation for :py:meth:`api_query` for full implementation | See the documentation for :py:meth:`api_query` for full implementation | ||||
| details. | |||||
| details. *tries*, *wait*, and *ignore_maxlag* are for maxlag; | |||||
| *ae_retry* is for AssertEdit. | |||||
| """ | """ | ||||
| since_last_query = time() - self._last_query_time # Throttling support | since_last_query = time() - self._last_query_time # Throttling support | ||||
| if since_last_query < self._wait_between_queries: | if since_last_query < self._wait_between_queries: | ||||
| @@ -247,7 +249,7 @@ class Site(object): | |||||
| gzipper = GzipFile(fileobj=stream) | gzipper = GzipFile(fileobj=stream) | ||||
| result = gzipper.read() | result = gzipper.read() | ||||
| return self._handle_api_query_result(result, params, tries, wait) | |||||
| return self._handle_api_result(result, params, tries, wait, ae_retry) | |||||
| def _build_api_query(self, params, ignore_maxlag): | def _build_api_query(self, params, ignore_maxlag): | ||||
| """Given API query params, return the URL to query and POST data.""" | """Given API query params, return the URL to query and POST data.""" | ||||
| @@ -257,7 +259,8 @@ class Site(object): | |||||
| url = ''.join((self.url, self._script_path, "/api.php")) | url = ''.join((self.url, self._script_path, "/api.php")) | ||||
| params["format"] = "json" # This is the only format we understand | params["format"] = "json" # This is the only format we understand | ||||
| if self._assert_edit: # If requested, ensure that we're logged in | |||||
| if self._assert_edit and params.get("action") != "login": | |||||
| # If requested, ensure that we're logged in | |||||
| params["assert"] = self._assert_edit | params["assert"] = self._assert_edit | ||||
| if self._maxlag and not ignore_maxlag: | if self._maxlag and not ignore_maxlag: | ||||
| # If requested, don't overload the servers: | # If requested, don't overload the servers: | ||||
| @@ -266,7 +269,7 @@ class Site(object): | |||||
| data = self._urlencode_utf8(params) | data = self._urlencode_utf8(params) | ||||
| return url, data | return url, data | ||||
| def _handle_api_query_result(self, result, params, tries, wait): | |||||
| def _handle_api_result(self, result, params, tries, wait, ae_retry): | |||||
| """Given the result of an API query, attempt to return useful data.""" | """Given the result of an API query, attempt to return useful data.""" | ||||
| try: | try: | ||||
| res = loads(result) # Try to parse as a JSON object | res = loads(result) # Try to parse as a JSON object | ||||
| @@ -277,8 +280,8 @@ class Site(object): | |||||
| try: | try: | ||||
| code = res["error"]["code"] | code = res["error"]["code"] | ||||
| info = res["error"]["info"] | info = res["error"]["info"] | ||||
| except (TypeError, KeyError): # Having these keys indicates a problem | |||||
| return res # All is well; return the decoded JSON | |||||
| except (TypeError, KeyError): # If there's no error code/info, return | |||||
| return res | |||||
| if code == "maxlag": # We've been throttled by the server | if code == "maxlag": # We've been throttled by the server | ||||
| if tries >= self._max_retries: | if tries >= self._max_retries: | ||||
| @@ -288,7 +291,22 @@ class Site(object): | |||||
| msg = 'Server says "{0}"; retrying in {1} seconds ({2}/{3})' | msg = 'Server says "{0}"; retrying in {1} seconds ({2}/{3})' | ||||
| self._logger.info(msg.format(info, wait, tries, self._max_retries)) | self._logger.info(msg.format(info, wait, tries, self._max_retries)) | ||||
| sleep(wait) | sleep(wait) | ||||
| return self._api_query(params, tries=tries, wait=wait*2) | |||||
| return self._api_query(params, tries, wait * 2, ae_retry=ae_retry) | |||||
| elif code in ["assertuserfailed", "assertbotfailed"]: # AssertEdit | |||||
| if ae_retry and all(self._login_info): | |||||
| # Try to log in if we got logged out: | |||||
| self._login(self._login_info) | |||||
| if "token" in params: # Fetch a new one; this is invalid now | |||||
| tparams = {"action": "tokens", "type": params["action"]} | |||||
| params["token"] = self._api_query(tparams, ae_retry=False) | |||||
| return self._api_query(params, tries, wait, ae_retry=False) | |||||
| if not all(self._login_info): | |||||
| e = "Assertion failed, and no login info was provided." | |||||
| elif code == "assertbotfailed": | |||||
| e = "Bot assertion failed: we don't have a bot flag!" | |||||
| else: | |||||
| e = "User assertion failed due to an unknown issue. Cookie problem?" | |||||
| raise exceptions.PermissionsError("AssertEdit: " + e) | |||||
| else: # Some unknown error occurred | else: # Some unknown error occurred | ||||
| e = 'API query failed: got error "{0}"; server says: "{1}".' | e = 'API query failed: got error "{0}"; server says: "{1}".' | ||||
| error = exceptions.APIError(e.format(code, info)) | error = exceptions.APIError(e.format(code, info)) | ||||