| @@ -11,6 +11,7 @@ class Command(BaseCommand): | |||
| def process(self, data): | |||
| self.site = wiki.get_site() | |||
| self.site._maxlag = None | |||
| self.data = data | |||
| if not data.args: | |||
| @@ -27,6 +27,7 @@ class Command(BaseCommand): | |||
| def process(self, data): | |||
| self.site = wiki.get_site() | |||
| self.site._maxlag = None | |||
| if data.line[1] == "JOIN": | |||
| notice = self.get_join_notice() | |||
| @@ -42,6 +42,6 @@ class Command(BaseCommand): | |||
| elif command == "VERSION": | |||
| default = "EarwigBot - 0.1-dev - Python/$1 https://github.com/earwig/earwigbot" | |||
| vers = config.metadata.get("ircVersion", default) | |||
| vers = config.irc.get("version", default) | |||
| vers = vers.replace("$1", platform.python_version()) | |||
| self.connection.notice(target, "\x01VERSION {0}\x01".format(vers)) | |||
| @@ -0,0 +1,38 @@ | |||
| # -*- coding: utf-8 -*- | |||
| from urllib import quote_plus | |||
| from classes import BaseCommand | |||
| import wiki | |||
| class Command(BaseCommand): | |||
| """Return a user's edit count.""" | |||
| name = "editcount" | |||
| def check(self, data): | |||
| commands = ["ec", "editcount"] | |||
| if data.is_command and data.command in commands: | |||
| return True | |||
| return False | |||
| def process(self, data): | |||
| if not data.args: | |||
| name = data.nick | |||
| else: | |||
| name = ' '.join(data.args) | |||
| site = wiki.get_site() | |||
| site._maxlag = None | |||
| user = site.get_user(name) | |||
| try: | |||
| count = user.editcount() | |||
| except wiki.UserNotFoundError: | |||
| msg = "the user \x0302{0}\x0301 does not exist." | |||
| self.connection.reply(data, msg.format(name)) | |||
| return | |||
| safe = quote_plus(user.name()) | |||
| url = "http://toolserver.org/~soxred93/pcount/index.php?name={0}&lang=en&wiki=wikipedia" | |||
| msg = "\x0302{0}\x0301 has {1} edits ({2})." | |||
| self.connection.reply(data, msg.format(name, count, url.format(safe))) | |||
| @@ -0,0 +1,31 @@ | |||
| # -*- coding: utf-8 -*- | |||
| import random | |||
| from classes import BaseCommand | |||
| class Command(BaseCommand): | |||
| """Praise people!""" | |||
| name = "praise" | |||
| def check(self, data): | |||
| commands = ["praise", "earwig", "leonard", "leonard^bloom", "groove", | |||
| "groovedog"] | |||
| return data.is_command and data.command in commands | |||
| def process(self, data): | |||
| if data.command == "earwig": | |||
| msg = "\x02Earwig\x0F is the bestest Python programmer ever!" | |||
| elif data.command in ["leonard", "leonard^bloom"]: | |||
| msg = "\x02Leonard^Bloom\x0F is the biggest slacker ever!" | |||
| elif data.command in ["groove", "groovedog"]: | |||
| msg = "\x02GrooveDog\x0F is the bestest heh evar!" | |||
| else: | |||
| if not data.args: | |||
| msg = "You use this command to praise certain people. Who they are is a secret." | |||
| else: | |||
| msg = "You're doing it wrong." | |||
| self.connection.reply(data, msg) | |||
| return | |||
| self.connection.say(data.chan, msg) | |||
| @@ -0,0 +1,63 @@ | |||
| # -*- coding: utf-8 -*- | |||
| import time | |||
| from classes import BaseCommand | |||
| import wiki | |||
| class Command(BaseCommand): | |||
| """Return when a user registered.""" | |||
| name = "registration" | |||
| def check(self, data): | |||
| commands = ["registration", "age"] | |||
| if data.is_command and data.command in commands: | |||
| return True | |||
| return False | |||
| def process(self, data): | |||
| if not data.args: | |||
| name = data.nick | |||
| else: | |||
| name = ' '.join(data.args) | |||
| site = wiki.get_site() | |||
| site._maxlag = None | |||
| user = site.get_user(name) | |||
| try: | |||
| reg = user.registration() | |||
| except wiki.UserNotFoundError: | |||
| msg = "the user \x0302{0}\x0301 does not exist." | |||
| self.connection.reply(data, msg.format(name)) | |||
| return | |||
| date = time.strftime("%b %d, %Y at %H:%M:%S UTC", reg) | |||
| age = self.get_diff(time.mktime(reg), time.mktime(time.gmtime())) | |||
| g = user.gender() | |||
| if g == "male": | |||
| gender = "He's" | |||
| elif g == "female": | |||
| gender = "She's" | |||
| else: | |||
| gender = "They're" | |||
| msg = "\x0302{0}\x0301 registered on {1}. {2} {3} old." | |||
| self.connection.reply(data, msg.format(name, date, gender, age)) | |||
| def get_diff(self, t1, t2): | |||
| parts = {"years": 31536000, "days": 86400, "hours": 3600, | |||
| "minutes": 60, "seconds": 1} | |||
| msg = [] | |||
| order = sorted(parts.items(), key=lambda x: x[1], reverse=True) | |||
| for key, value in order: | |||
| num = 0 | |||
| while t2 - t1 > value: | |||
| t1 += value | |||
| num += 1 | |||
| if num or (not num and msg): | |||
| msg.append(" ".join((str(num), key))) | |||
| return ", ".join(msg) | |||
| @@ -4,7 +4,7 @@ from classes import BaseCommand | |||
| import wiki | |||
| class Command(BaseCommand): | |||
| """Retrieve a list of rights for a given username.""" | |||
| """Retrieve a list of rights for a given name.""" | |||
| name = "rights" | |||
| def check(self, data): | |||
| @@ -15,18 +15,19 @@ class Command(BaseCommand): | |||
| def process(self, data): | |||
| if not data.args: | |||
| self.connection.reply(data, "who do you want me to look up?") | |||
| return | |||
| name = data.nick | |||
| else: | |||
| name = ' '.join(data.args) | |||
| username = ' '.join(data.args) | |||
| site = wiki.get_site() | |||
| user = site.get_user(username) | |||
| site._maxlag = None | |||
| user = site.get_user(name) | |||
| try: | |||
| rights = user.groups() | |||
| except wiki.UserNotFoundError: | |||
| msg = "the user \x0302{0}\x0301 does not exist." | |||
| self.connection.reply(data, msg.format(username)) | |||
| self.connection.reply(data, msg.format(name)) | |||
| return | |||
| try: | |||
| @@ -34,4 +35,4 @@ class Command(BaseCommand): | |||
| except ValueError: | |||
| pass | |||
| msg = "the rights for \x0302{0}\x0301 are {1}." | |||
| self.connection.reply(data, msg.format(username, ', '.join(rights))) | |||
| self.connection.reply(data, msg.format(name, ', '.join(rights))) | |||
| @@ -16,6 +16,16 @@ class Category(Page): | |||
| members -- returns a list of titles in the category | |||
| """ | |||
| def __repr__(self): | |||
| """Returns the canonical string representation of the Category.""" | |||
| res = ", ".join(("Category(title={0!r}", "follow_redirects={1!r}", | |||
| "site={2!r})")) | |||
| return res.format(self._title, self._follow_redirects, self._site) | |||
| def __str__(self): | |||
| """Returns a nice string representation of the Category.""" | |||
| return '<Category "{0}" of {1}>'.format(self.title(), str(self._site)) | |||
| def members(self, limit=50): | |||
| """Returns a list of titles in the category. | |||
| @@ -3,7 +3,7 @@ | |||
| """ | |||
| EarwigBot's Wiki Toolset: Exceptions | |||
| This module contains all exceptions used by the wiki package. | |||
| This module contains all exceptions used by the wiki package. There are a lot. | |||
| """ | |||
| class WikiToolsetError(Exception): | |||
| @@ -22,11 +22,6 @@ class LoginError(WikiToolsetError): | |||
| """An error occured while trying to login. Perhaps the username/password is | |||
| incorrect.""" | |||
| class PermissionsError(WikiToolsetError): | |||
| """We tried to do something we don't have permission to, like a non-admin | |||
| trying to delete a page, or trying to edit a page when no login information | |||
| was provided.""" | |||
| class NamespaceNotFoundError(WikiToolsetError): | |||
| """A requested namespace name or namespace ID does not exist.""" | |||
| @@ -45,3 +40,27 @@ class RedirectError(WikiToolsetError): | |||
| class UserNotFoundError(WikiToolsetError): | |||
| """Attempting to get certain information about a user that does not | |||
| exist.""" | |||
| class EditError(WikiToolsetError): | |||
| """We got some error while editing. Sometimes, a subclass of this exception | |||
| will be used, like PermissionsError or EditConflictError.""" | |||
| class PermissionsError(EditError): | |||
| """We tried to do something we don't have permission to, like a non-admin | |||
| trying to delete a page, or trying to edit a page when no login information | |||
| was provided.""" | |||
| class EditConflictError(EditError): | |||
| """We've gotten an edit conflict or a (rarer) delete/recreate conflict.""" | |||
| class NoContentError(EditError): | |||
| """We tried to create a page or new section with no content.""" | |||
| class ContentTooBigError(EditError): | |||
| """The edit we tried to push exceeded the article size limit.""" | |||
| class SpamDetectedError(EditError): | |||
| """The spam filter refused our edit.""" | |||
| class FilteredError(EditError): | |||
| """The edit filter refused our edit.""" | |||
| @@ -86,7 +86,9 @@ def _get_site_object_from_dict(name, d): | |||
| namespaces = d.get("namespaces", {}) | |||
| login = (config.wiki.get("username"), config.wiki.get("password")) | |||
| cookiejar = _get_cookiejar() | |||
| user_agent = config.metadata.get("userAgent") | |||
| user_agent = config.wiki.get("userAgent") | |||
| assert_edit = config.wiki.get("assert") | |||
| maxlag = config.wiki.get("maxlag") | |||
| if user_agent: | |||
| user_agent = user_agent.replace("$1", platform.python_version()) | |||
| @@ -102,7 +104,7 @@ def _get_site_object_from_dict(name, d): | |||
| return Site(name=name, project=project, lang=lang, base_url=base_url, | |||
| article_path=article_path, script_path=script_path, sql=sql, | |||
| namespaces=namespaces, login=login, cookiejar=cookiejar, | |||
| user_agent=user_agent) | |||
| user_agent=user_agent, assert_edit=assert_edit, maxlag=maxlag) | |||
| def get_site(name=None, project=None, lang=None): | |||
| """Returns a Site instance based on information from our config file. | |||
| @@ -1,6 +1,8 @@ | |||
| # -*- coding: utf-8 -*- | |||
| from hashlib import md5 | |||
| import re | |||
| from time import gmtime, strftime | |||
| from urllib import quote | |||
| from wiki.exceptions import * | |||
| @@ -25,7 +27,9 @@ class Page(object): | |||
| is_redirect -- returns True if the page is a redirect, else False | |||
| toggle_talk -- returns a content page's talk page, or vice versa | |||
| get -- returns page content | |||
| get_redirect_target -- if the page is a redirect, returns its destination | |||
| get_redirect_target -- if the page is a redirect, returns its destination | |||
| edit -- replaces the page's content or creates a new page | |||
| add_section -- add a new section at the bottom of the page | |||
| """ | |||
| def __init__(self, site, title, follow_redirects=False): | |||
| @@ -54,6 +58,11 @@ class Page(object): | |||
| self._content = None | |||
| self._creator = None | |||
| # Attributes used for editing/deleting/protecting/etc: | |||
| self._token = None | |||
| self._basetimestamp = None | |||
| self._starttimestamp = None | |||
| # Try to determine the page's namespace using our site's namespace | |||
| # converter: | |||
| prefix = self._title.split(":", 1)[0] | |||
| @@ -72,6 +81,16 @@ class Page(object): | |||
| else: | |||
| self._is_talkpage = self._namespace % 2 == 1 | |||
| def __repr__(self): | |||
| """Returns the canonical string representation of the Page.""" | |||
| res = ", ".join(("Page(title={0!r}", "follow_redirects={1!r}", | |||
| "site={2!r})")) | |||
| return res.format(self._title, self._follow_redirects, self._site) | |||
| def __str__(self): | |||
| """Returns a nice string representation of the Page.""" | |||
| return '<Page "{0}" of {1}>'.format(self.title(), str(self._site)) | |||
| def _force_validity(self): | |||
| """Used to ensure that our page's title is valid. | |||
| @@ -124,16 +143,16 @@ class Page(object): | |||
| """Loads various data from the API in a single query. | |||
| Loads self._title, ._exists, ._is_redirect, ._pageid, ._fullurl, | |||
| ._protection, ._namespace, ._is_talkpage, ._creator, and ._lastrevid | |||
| using the API. It will do a query of its own unless `result` is | |||
| provided, in which case we'll pretend `result` is what the query | |||
| returned. | |||
| ._protection, ._namespace, ._is_talkpage, ._creator, ._lastrevid, | |||
| ._token, and ._starttimestamp using the API. It will do a query of | |||
| its own unless `result` is provided, in which case we'll pretend | |||
| `result` is what the query returned. | |||
| Assuming the API is sound, this should not raise any exceptions. | |||
| """ | |||
| if result is None: | |||
| params = {"action": "query", "rvprop": "user", "rvdir": "newer", | |||
| "prop": "info|revisions", "rvlimit": 1, | |||
| params = {"action": "query", "rvprop": "user", "intoken": "edit", | |||
| "prop": "info|revisions", "rvlimit": 1, "rvdir": "newer", | |||
| "titles": self._title, "inprop": "protection|url"} | |||
| result = self._site._api_query(params) | |||
| @@ -168,6 +187,13 @@ class Page(object): | |||
| self._fullurl = res["fullurl"] | |||
| self._protection = res["protection"] | |||
| try: | |||
| self._token = res["edittoken"] | |||
| except KeyError: | |||
| pass | |||
| else: | |||
| self._starttimestamp = strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) | |||
| # We've determined the namespace and talkpage status in __init__() | |||
| # based on the title, but now we can be sure: | |||
| self._namespace = res["ns"] | |||
| @@ -192,13 +218,13 @@ class Page(object): | |||
| """ | |||
| if result is None: | |||
| params = {"action": "query", "prop": "revisions", "rvlimit": 1, | |||
| "rvprop": "content", "titles": self._title} | |||
| "rvprop": "content|timestamp", "titles": self._title} | |||
| result = self._site._api_query(params) | |||
| res = result["query"]["pages"].values()[0] | |||
| try: | |||
| content = res["revisions"][0]["*"] | |||
| self._content = content | |||
| self._content = res["revisions"][0]["*"] | |||
| self._basetimestamp = res["revisions"][0]["timestamp"] | |||
| except KeyError: | |||
| # This can only happen if the page was deleted since we last called | |||
| # self._load_attributes(). In that case, some of our attributes are | |||
| @@ -206,6 +232,166 @@ class Page(object): | |||
| self._load_attributes() | |||
| self._force_existence() | |||
| def _edit(self, params=None, text=None, summary=None, minor=None, bot=None, | |||
| force=None, section=None, captcha_id=None, captcha_word=None, | |||
| tries=0): | |||
| """Edit the page! | |||
| If `params` is given, we'll use it as our API query parameters. | |||
| Otherwise, we'll build params using the given kwargs via | |||
| _build_edit_params(). | |||
| We'll then try to do the API query, and catch any errors the API raises | |||
| in _handle_edit_errors(). We'll then throw these back as subclasses of | |||
| EditError. | |||
| """ | |||
| # Try to get our edit token, and die if we can't: | |||
| if not self._token: | |||
| self._load_attributes() | |||
| if not self._token: | |||
| e = "You don't have permission to edit this page." | |||
| raise PermissionsError(e) | |||
| # Weed out invalid pages before we get too far: | |||
| self._force_validity() | |||
| # Build our API query string: | |||
| if not params: | |||
| params = self._build_edit_params(text, summary, minor, bot, force, | |||
| section, captcha_id, captcha_word) | |||
| else: # Make sure we have the right token: | |||
| params["token"] = self._token | |||
| # Try the API query, catching most errors with our handler: | |||
| try: | |||
| result = self._site._api_query(params) | |||
| except SiteAPIError as error: | |||
| if not hasattr(error, "code"): | |||
| raise # We can only handle errors with a code attribute | |||
| result = self._handle_edit_errors(error, params, tries) | |||
| # If everything was successful, reset invalidated attributes: | |||
| if result["edit"]["result"] == "Success": | |||
| self._content = None | |||
| self._basetimestamp = None | |||
| self._exists = 0 | |||
| 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 EditError(result["edit"]) | |||
| self._handle_assert_edit(assertion, params, tries) | |||
| def _build_edit_params(self, text, summary, minor, bot, force, section, | |||
| captcha_id, captcha_word): | |||
| """Given some keyword arguments, build an API edit query string.""" | |||
| hashed = md5(text).hexdigest() # Checksum to ensure text is correct | |||
| params = {"action": "edit", "title": self._title, "text": text, | |||
| "token": self._token, "summary": summary, "md5": hashed} | |||
| if section: | |||
| params["section"] = section | |||
| if captcha_id and captcha_word: | |||
| params["captchaid"] = captcha_id | |||
| params["captchaword"] = captcha_word | |||
| if minor: | |||
| params["minor"] = "true" | |||
| else: | |||
| params["notminor"] = "true" | |||
| if bot: | |||
| params["bot"] = "true" | |||
| if not force: | |||
| params["starttimestamp"] = self._starttimestamp | |||
| if self._basetimestamp: | |||
| params["basetimestamp"] = self._basetimestamp | |||
| if self._exists == 2: | |||
| # Page does not exist; don't edit if it already exists: | |||
| params["createonly"] = "true" | |||
| else: | |||
| params["recreate"] = "true" | |||
| return params | |||
| def _handle_edit_errors(self, error, params, tries): | |||
| """If our edit fails due to some error, try to handle it. | |||
| We'll either raise an appropriate exception (for example, if the page | |||
| 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). | |||
| """ | |||
| if error.code in ["noedit", "cantcreate", "protectedtitle", | |||
| "noimageredirect"]: | |||
| raise PermissionsError(error.info) | |||
| elif error.code in ["noedit-anon", "cantcreate-anon", | |||
| "noimageredirect-anon"]: | |||
| if not all(self._site._login_info): | |||
| # Insufficient login info: | |||
| raise 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 LoginError(e) | |||
| elif error.code in ["editconflict", "pagedeleted", "articleexists"]: | |||
| # These attributes are now invalidated: | |||
| self._content = None | |||
| self._basetimestamp = None | |||
| self._exists = 0 | |||
| raise EditConflictError(error.info) | |||
| elif error.code in ["emptypage", "emptynewsection"]: | |||
| raise NoContentError(error.info) | |||
| elif error.code == "contenttoobig": | |||
| raise ContentTooBigError(error.info) | |||
| elif error.code == "spamdetected": | |||
| raise SpamDetectedError(error.info) | |||
| elif error.code == "filtered": | |||
| raise FilteredError(error.info) | |||
| raise 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 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 LoginError(e) | |||
| elif assertion == "bot": | |||
| e = "AssertEdit: bot assertion failed; we don't have a bot flag!" | |||
| raise PermissionsError(e) | |||
| # Unknown assertion, maybe "true", "false", or "exists": | |||
| e = "AssertEdit: assertion '{0}' failed.".format(assertion) | |||
| raise PermissionsError(e) | |||
| def title(self, force=False): | |||
| """Returns the Page's title, or pagename. | |||
| @@ -394,9 +580,9 @@ class Page(object): | |||
| if force or self._exists == 0: | |||
| # Kill two birds with one stone by doing an API query for both our | |||
| # attributes and our page content: | |||
| params = {"action": "query", "rvprop": "content", "rvlimit": 1, | |||
| params = {"action": "query", "rvlimit": 1, "titles": self._title, | |||
| "prop": "info|revisions", "inprop": "protection|url", | |||
| "titles": self._title} | |||
| "intoken": "edit", "rvprop": "content|timestamp"} | |||
| result = self._site._api_query(params) | |||
| self._load_attributes(result=result) | |||
| self._force_existence() | |||
| @@ -438,3 +624,32 @@ class Page(object): | |||
| except IndexError: | |||
| e = "The page does not appear to have a redirect target." | |||
| raise RedirectError(e) | |||
| def edit(self, text, summary, minor=False, bot=True, force=False): | |||
| """Replaces the page's content or creates a new page. | |||
| `text` is the new page content, with `summary` as the edit summary. | |||
| If `minor` is True, the edit will be marked as minor. If `bot` is true, | |||
| the edit will be marked as a bot edit, but only if we actually have a | |||
| bot flag. | |||
| Use `force` to push the new content even if there's an edit conflict or | |||
| the page was deleted/recreated between getting our edit token and | |||
| editing our page. Be careful with this! | |||
| """ | |||
| self._edit(text=text, summary=summary, minor=minor, bot=bot, | |||
| force=force) | |||
| def add_section(self, text, title, minor=False, bot=True, force=False): | |||
| """Adds a new section to the bottom of the page. | |||
| The arguments for this are the same as those for edit(), but instead of | |||
| providing a summary, you provide a section title. | |||
| Likewise, raised exceptions are the same as edit()'s. | |||
| This should create the page if it does not already exist, with just the | |||
| new section as content. | |||
| """ | |||
| self._edit(text=text, summary=title, minor=minor, bot=bot, force=force, | |||
| section="new") | |||
| @@ -5,6 +5,7 @@ from gzip import GzipFile | |||
| from json import loads | |||
| from re import escape as re_escape, match as re_match | |||
| from StringIO import StringIO | |||
| from time import sleep | |||
| from urllib import unquote_plus, urlencode | |||
| from urllib2 import build_opener, HTTPCookieProcessor, URLError | |||
| from urlparse import urlparse | |||
| @@ -41,7 +42,7 @@ class Site(object): | |||
| def __init__(self, name=None, project=None, lang=None, base_url=None, | |||
| article_path=None, script_path=None, sql=(None, None), | |||
| namespaces=None, login=(None, None), cookiejar=None, | |||
| user_agent=None): | |||
| user_agent=None, assert_edit=None, maxlag=None): | |||
| """Constructor for new Site instances. | |||
| This probably isn't necessary to call yourself unless you're building a | |||
| @@ -69,6 +70,11 @@ class Site(object): | |||
| self._sql = sql | |||
| self._namespaces = namespaces | |||
| # Attributes used when querying the API: | |||
| self._assert_edit = assert_edit | |||
| self._maxlag = maxlag | |||
| self._max_retries = 5 | |||
| # Set up cookiejar and URL opener for making API queries: | |||
| if cookiejar is not None: | |||
| self._cookiejar = cookiejar | |||
| @@ -90,22 +96,50 @@ class Site(object): | |||
| if logged_in_as is None or name != logged_in_as: | |||
| self._login(login) | |||
| def _api_query(self, params): | |||
| def __repr__(self): | |||
| """Returns the canonical string representation of the Site.""" | |||
| res = ", ".join(( | |||
| "Site(name={_name!r}", "project={_project!r}", "lang={_lang!r}", | |||
| "base_url={_base_url!r}", "article_path={_article_path!r}", | |||
| "script_path={_script_path!r}", "assert_edit={_assert_edit!r}", | |||
| "maxlag={_maxlag!r}", "sql={_sql!r}", "login={0}", | |||
| "user_agent={2!r}", "cookiejar={1})" | |||
| )) | |||
| name, password = self._login_info | |||
| login = "({0}, {1})".format(repr(name), "hidden" if password else None) | |||
| cookies = self._cookiejar.__class__.__name__ | |||
| try: | |||
| cookies += "({0!r})".format(self._cookiejar.filename) | |||
| except AttributeError: | |||
| cookies += "()" | |||
| agent = self._opener.addheaders[0][1] | |||
| return res.format(login, cookies, agent, **self.__dict__) | |||
| def __str__(self): | |||
| """Returns a nice string representation of the Site.""" | |||
| res = "<Site {0} ({1}:{2}) at {3}>" | |||
| return res.format(self.name(), self.project(), self.lang(), | |||
| self.domain()) | |||
| def _api_query(self, params, tries=0, wait=5): | |||
| """Do an API query with `params` as a dict of parameters. | |||
| This will first attempt to construct an API url from self._base_url and | |||
| self._script_path. We need both of these, or else we'll raise | |||
| SiteAPIError. | |||
| We'll encode the given params, adding format=json along the way, and | |||
| make the request through self._opener, which has built-in cookie | |||
| We'll encode the given params, adding format=json along the way, as | |||
| well as &assert= and &maxlag= based on self._assert_edit and _maxlag. | |||
| We make the request through self._opener, which has built-in cookie | |||
| support via self._cookiejar, a User-Agent (wiki.constants.USER_AGENT), | |||
| and Accept-Encoding set to "gzip". | |||
| Assuming everything went well, we'll gunzip the data (if compressed), | |||
| load it as a JSON object, and return it. | |||
| If our request failed, we'll raise SiteAPIError with details. | |||
| If our request failed for some reason, we'll raise SiteAPIError with | |||
| details. If that reason was due to maxlag, we'll sleep for a bit and | |||
| then repeat the query until we exceed self._max_retries. | |||
| There's helpful MediaWiki API documentation at | |||
| <http://www.mediawiki.org/wiki/API>. | |||
| @@ -115,7 +149,13 @@ class Site(object): | |||
| raise SiteAPIError(e) | |||
| url = ''.join((self._base_url, self._script_path, "/api.php")) | |||
| params["format"] = "json" # This is the only format we understand | |||
| if self._assert_edit: # If requested, ensure that we're logged in | |||
| params["assert"] = self._assert_edit | |||
| if self._maxlag: # If requested, don't overload the servers | |||
| params["maxlag"] = self._maxlag | |||
| data = urlencode(params) | |||
| print url, data # debug code | |||
| @@ -124,21 +164,46 @@ class Site(object): | |||
| response = self._opener.open(url, data) | |||
| except URLError as error: | |||
| if hasattr(error, "reason"): | |||
| e = "API query at {0} failed because {1}." | |||
| e = e.format(error.geturl, error.reason) | |||
| e = "API query failed: {0}.".format(error.reason) | |||
| elif hasattr(error, "code"): | |||
| e = "API query at {0} failed; got an error code of {1}." | |||
| e = e.format(error.geturl, error.code) | |||
| e = "API query failed: got an error code of {0}." | |||
| e = e.format(error.code) | |||
| else: | |||
| e = "API query failed." | |||
| raise SiteAPIError(e) | |||
| result = response.read() | |||
| if response.headers.get("Content-Encoding") == "gzip": | |||
| stream = StringIO(result) | |||
| gzipper = GzipFile(fileobj=stream) | |||
| result = gzipper.read() | |||
| try: | |||
| res = loads(result) # Parse as a JSON object | |||
| except ValueError: | |||
| e = "API query failed: JSON could not be decoded." | |||
| raise SiteAPIError(e) | |||
| try: | |||
| code = res["error"]["code"] | |||
| info = res["error"]["info"] | |||
| except (TypeError, KeyError): | |||
| return res | |||
| if code == "maxlag": | |||
| if tries >= self._max_retries: | |||
| e = "Maximum number of retries reached ({0})." | |||
| raise SiteAPIError(e.format(self._max_retries)) | |||
| tries += 1 | |||
| msg = 'Server says: "{0}". Retrying in {1} seconds ({2}/{3}).' | |||
| print msg.format(info, wait, tries, self._max_retries) | |||
| sleep(wait) | |||
| return self._api_query(params, tries=tries, wait=wait*3) | |||
| else: | |||
| result = response.read() | |||
| if response.headers.get("Content-Encoding") == "gzip": | |||
| stream = StringIO(result) | |||
| gzipper = GzipFile(fileobj=stream) | |||
| result = gzipper.read() | |||
| return loads(result) # Parse as a JSON object | |||
| e = 'API query failed: got error "{0}"; server says: "{1}".' | |||
| error = SiteAPIError(e.format(code, info)) | |||
| error.code, error.info = code, info | |||
| raise error | |||
| def _load_attributes(self, force=False): | |||
| """Load data about our Site from the API. | |||
| @@ -1,6 +1,6 @@ | |||
| # -*- coding: utf-8 -*- | |||
| from time import strptime | |||
| from time import gmtime, strptime | |||
| from wiki.constants import * | |||
| from wiki.exceptions import UserNotFoundError | |||
| @@ -45,6 +45,14 @@ class User(object): | |||
| self._site = site | |||
| self._name = name | |||
| def __repr__(self): | |||
| """Returns the canonical string representation of the User.""" | |||
| return "User(name={0!r}, site={1!r})".format(self._name, self._site) | |||
| def __str__(self): | |||
| """Returns a nice string representation of the User.""" | |||
| return '<User "{0}" of {1}>'.format(self.name(), str(self._site)) | |||
| def _get_attribute(self, attr, force): | |||
| """Internally used to get an attribute by name. | |||
| @@ -101,7 +109,12 @@ class User(object): | |||
| self._editcount = res["editcount"] | |||
| reg = res["registration"] | |||
| self._registration = strptime(reg, "%Y-%m-%dT%H:%M:%SZ") | |||
| try: | |||
| self._registration = strptime(reg, "%Y-%m-%dT%H:%M:%SZ") | |||
| except TypeError: | |||
| # Sometimes the API doesn't give a date; the user's probably really | |||
| # old. There's nothing else we can do! | |||
| self._registration = gmtime(0) | |||
| try: | |||
| res["emailable"] | |||