| @@ -11,6 +11,7 @@ class Command(BaseCommand): | |||||
| def process(self, data): | def process(self, data): | ||||
| self.site = wiki.get_site() | self.site = wiki.get_site() | ||||
| self.site._maxlag = None | |||||
| self.data = data | self.data = data | ||||
| if not data.args: | if not data.args: | ||||
| @@ -27,6 +27,7 @@ class Command(BaseCommand): | |||||
| def process(self, data): | def process(self, data): | ||||
| self.site = wiki.get_site() | self.site = wiki.get_site() | ||||
| self.site._maxlag = None | |||||
| if data.line[1] == "JOIN": | if data.line[1] == "JOIN": | ||||
| notice = self.get_join_notice() | notice = self.get_join_notice() | ||||
| @@ -42,6 +42,6 @@ class Command(BaseCommand): | |||||
| elif command == "VERSION": | elif command == "VERSION": | ||||
| default = "EarwigBot - 0.1-dev - Python/$1 https://github.com/earwig/earwigbot" | 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()) | vers = vers.replace("$1", platform.python_version()) | ||||
| self.connection.notice(target, "\x01VERSION {0}\x01".format(vers)) | 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 | import wiki | ||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||
| """Retrieve a list of rights for a given username.""" | |||||
| """Retrieve a list of rights for a given name.""" | |||||
| name = "rights" | name = "rights" | ||||
| def check(self, data): | def check(self, data): | ||||
| @@ -15,18 +15,19 @@ class Command(BaseCommand): | |||||
| def process(self, data): | def process(self, data): | ||||
| if not data.args: | 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() | site = wiki.get_site() | ||||
| user = site.get_user(username) | |||||
| site._maxlag = None | |||||
| user = site.get_user(name) | |||||
| try: | try: | ||||
| rights = user.groups() | rights = user.groups() | ||||
| except wiki.UserNotFoundError: | except wiki.UserNotFoundError: | ||||
| msg = "the user \x0302{0}\x0301 does not exist." | msg = "the user \x0302{0}\x0301 does not exist." | ||||
| self.connection.reply(data, msg.format(username)) | |||||
| self.connection.reply(data, msg.format(name)) | |||||
| return | return | ||||
| try: | try: | ||||
| @@ -34,4 +35,4 @@ class Command(BaseCommand): | |||||
| except ValueError: | except ValueError: | ||||
| pass | pass | ||||
| msg = "the rights for \x0302{0}\x0301 are {1}." | 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 | 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): | def members(self, limit=50): | ||||
| """Returns a list of titles in the category. | """Returns a list of titles in the category. | ||||
| @@ -3,7 +3,7 @@ | |||||
| """ | """ | ||||
| EarwigBot's Wiki Toolset: Exceptions | 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): | class WikiToolsetError(Exception): | ||||
| @@ -22,11 +22,6 @@ class LoginError(WikiToolsetError): | |||||
| """An error occured while trying to login. Perhaps the username/password is | """An error occured while trying to login. Perhaps the username/password is | ||||
| incorrect.""" | 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): | class NamespaceNotFoundError(WikiToolsetError): | ||||
| """A requested namespace name or namespace ID does not exist.""" | """A requested namespace name or namespace ID does not exist.""" | ||||
| @@ -45,3 +40,27 @@ class RedirectError(WikiToolsetError): | |||||
| class UserNotFoundError(WikiToolsetError): | class UserNotFoundError(WikiToolsetError): | ||||
| """Attempting to get certain information about a user that does not | """Attempting to get certain information about a user that does not | ||||
| exist.""" | 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", {}) | namespaces = d.get("namespaces", {}) | ||||
| login = (config.wiki.get("username"), config.wiki.get("password")) | login = (config.wiki.get("username"), config.wiki.get("password")) | ||||
| cookiejar = _get_cookiejar() | 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: | if user_agent: | ||||
| user_agent = user_agent.replace("$1", platform.python_version()) | 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, | return Site(name=name, project=project, lang=lang, base_url=base_url, | ||||
| article_path=article_path, script_path=script_path, sql=sql, | article_path=article_path, script_path=script_path, sql=sql, | ||||
| namespaces=namespaces, login=login, cookiejar=cookiejar, | 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): | def get_site(name=None, project=None, lang=None): | ||||
| """Returns a Site instance based on information from our config file. | """Returns a Site instance based on information from our config file. | ||||
| @@ -1,6 +1,8 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from hashlib import md5 | |||||
| import re | import re | ||||
| from time import gmtime, strftime | |||||
| from urllib import quote | from urllib import quote | ||||
| from wiki.exceptions import * | from wiki.exceptions import * | ||||
| @@ -25,7 +27,9 @@ class Page(object): | |||||
| is_redirect -- returns True if the page is a redirect, else False | is_redirect -- returns True if the page is a redirect, else False | ||||
| toggle_talk -- returns a content page's talk page, or vice versa | toggle_talk -- returns a content page's talk page, or vice versa | ||||
| get -- returns page content | 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): | def __init__(self, site, title, follow_redirects=False): | ||||
| @@ -54,6 +58,11 @@ class Page(object): | |||||
| self._content = None | self._content = None | ||||
| self._creator = 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 | # Try to determine the page's namespace using our site's namespace | ||||
| # converter: | # converter: | ||||
| prefix = self._title.split(":", 1)[0] | prefix = self._title.split(":", 1)[0] | ||||
| @@ -72,6 +81,16 @@ class Page(object): | |||||
| else: | else: | ||||
| self._is_talkpage = self._namespace % 2 == 1 | 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): | def _force_validity(self): | ||||
| """Used to ensure that our page's title is valid. | """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 various data from the API in a single query. | ||||
| Loads self._title, ._exists, ._is_redirect, ._pageid, ._fullurl, | 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. | Assuming the API is sound, this should not raise any exceptions. | ||||
| """ | """ | ||||
| if result is None: | 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"} | "titles": self._title, "inprop": "protection|url"} | ||||
| result = self._site._api_query(params) | result = self._site._api_query(params) | ||||
| @@ -168,6 +187,13 @@ class Page(object): | |||||
| self._fullurl = res["fullurl"] | self._fullurl = res["fullurl"] | ||||
| self._protection = res["protection"] | 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__() | # We've determined the namespace and talkpage status in __init__() | ||||
| # based on the title, but now we can be sure: | # based on the title, but now we can be sure: | ||||
| self._namespace = res["ns"] | self._namespace = res["ns"] | ||||
| @@ -192,13 +218,13 @@ class Page(object): | |||||
| """ | """ | ||||
| if result is None: | if result is None: | ||||
| params = {"action": "query", "prop": "revisions", "rvlimit": 1, | params = {"action": "query", "prop": "revisions", "rvlimit": 1, | ||||
| "rvprop": "content", "titles": self._title} | |||||
| "rvprop": "content|timestamp", "titles": self._title} | |||||
| result = self._site._api_query(params) | result = self._site._api_query(params) | ||||
| res = result["query"]["pages"].values()[0] | res = result["query"]["pages"].values()[0] | ||||
| try: | try: | ||||
| content = res["revisions"][0]["*"] | |||||
| self._content = content | |||||
| self._content = res["revisions"][0]["*"] | |||||
| self._basetimestamp = res["revisions"][0]["timestamp"] | |||||
| except KeyError: | except KeyError: | ||||
| # This can only happen if the page was deleted since we last called | # This can only happen if the page was deleted since we last called | ||||
| # self._load_attributes(). In that case, some of our attributes are | # self._load_attributes(). In that case, some of our attributes are | ||||
| @@ -206,6 +232,166 @@ class Page(object): | |||||
| self._load_attributes() | self._load_attributes() | ||||
| self._force_existence() | 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): | def title(self, force=False): | ||||
| """Returns the Page's title, or pagename. | """Returns the Page's title, or pagename. | ||||
| @@ -394,9 +580,9 @@ class Page(object): | |||||
| if force or self._exists == 0: | if force or self._exists == 0: | ||||
| # Kill two birds with one stone by doing an API query for both our | # Kill two birds with one stone by doing an API query for both our | ||||
| # attributes and our page content: | # 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", | "prop": "info|revisions", "inprop": "protection|url", | ||||
| "titles": self._title} | |||||
| "intoken": "edit", "rvprop": "content|timestamp"} | |||||
| result = self._site._api_query(params) | result = self._site._api_query(params) | ||||
| self._load_attributes(result=result) | self._load_attributes(result=result) | ||||
| self._force_existence() | self._force_existence() | ||||
| @@ -438,3 +624,32 @@ class Page(object): | |||||
| except IndexError: | except IndexError: | ||||
| e = "The page does not appear to have a redirect target." | e = "The page does not appear to have a redirect target." | ||||
| raise RedirectError(e) | 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 json import loads | ||||
| from re import escape as re_escape, match as re_match | from re import escape as re_escape, match as re_match | ||||
| from StringIO import StringIO | from StringIO import StringIO | ||||
| from time import sleep | |||||
| from urllib import unquote_plus, urlencode | from urllib import unquote_plus, urlencode | ||||
| from urllib2 import build_opener, HTTPCookieProcessor, URLError | from urllib2 import build_opener, HTTPCookieProcessor, URLError | ||||
| from urlparse import urlparse | from urlparse import urlparse | ||||
| @@ -41,7 +42,7 @@ class Site(object): | |||||
| def __init__(self, name=None, project=None, lang=None, base_url=None, | def __init__(self, name=None, project=None, lang=None, base_url=None, | ||||
| article_path=None, script_path=None, sql=(None, None), | article_path=None, script_path=None, sql=(None, None), | ||||
| namespaces=None, login=(None, None), cookiejar=None, | namespaces=None, login=(None, None), cookiejar=None, | ||||
| user_agent=None): | |||||
| user_agent=None, assert_edit=None, maxlag=None): | |||||
| """Constructor for new Site instances. | """Constructor for new Site instances. | ||||
| This probably isn't necessary to call yourself unless you're building a | This probably isn't necessary to call yourself unless you're building a | ||||
| @@ -69,6 +70,11 @@ class Site(object): | |||||
| self._sql = sql | self._sql = sql | ||||
| self._namespaces = namespaces | 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: | # Set up cookiejar and URL opener for making API queries: | ||||
| if cookiejar is not None: | if cookiejar is not None: | ||||
| self._cookiejar = cookiejar | self._cookiejar = cookiejar | ||||
| @@ -90,22 +96,50 @@ class Site(object): | |||||
| if logged_in_as is None or name != logged_in_as: | if logged_in_as is None or name != logged_in_as: | ||||
| self._login(login) | 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. | """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 | 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 | self._script_path. We need both of these, or else we'll raise | ||||
| SiteAPIError. | 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), | support via self._cookiejar, a User-Agent (wiki.constants.USER_AGENT), | ||||
| and Accept-Encoding set to "gzip". | and Accept-Encoding set to "gzip". | ||||
| Assuming everything went well, we'll gunzip the data (if compressed), | Assuming everything went well, we'll gunzip the data (if compressed), | ||||
| load it as a JSON object, and return it. | 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 | There's helpful MediaWiki API documentation at | ||||
| <http://www.mediawiki.org/wiki/API>. | <http://www.mediawiki.org/wiki/API>. | ||||
| @@ -115,7 +149,13 @@ class Site(object): | |||||
| raise SiteAPIError(e) | raise SiteAPIError(e) | ||||
| url = ''.join((self._base_url, self._script_path, "/api.php")) | url = ''.join((self._base_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 | |||||
| params["assert"] = self._assert_edit | |||||
| if self._maxlag: # If requested, don't overload the servers | |||||
| params["maxlag"] = self._maxlag | |||||
| data = urlencode(params) | data = urlencode(params) | ||||
| print url, data # debug code | print url, data # debug code | ||||
| @@ -124,21 +164,46 @@ class Site(object): | |||||
| response = self._opener.open(url, data) | response = self._opener.open(url, data) | ||||
| except URLError as error: | except URLError as error: | ||||
| if hasattr(error, "reason"): | 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"): | 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: | else: | ||||
| e = "API query failed." | e = "API query failed." | ||||
| raise SiteAPIError(e) | 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: | 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): | def _load_attributes(self, force=False): | ||||
| """Load data about our Site from the API. | """Load data about our Site from the API. | ||||
| @@ -1,6 +1,6 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| from time import strptime | |||||
| from time import gmtime, strptime | |||||
| from wiki.constants import * | from wiki.constants import * | ||||
| from wiki.exceptions import UserNotFoundError | from wiki.exceptions import UserNotFoundError | ||||
| @@ -45,6 +45,14 @@ class User(object): | |||||
| self._site = site | self._site = site | ||||
| self._name = name | 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): | def _get_attribute(self, attr, force): | ||||
| """Internally used to get an attribute by name. | """Internally used to get an attribute by name. | ||||
| @@ -101,7 +109,12 @@ class User(object): | |||||
| self._editcount = res["editcount"] | self._editcount = res["editcount"] | ||||
| reg = res["registration"] | 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: | try: | ||||
| res["emailable"] | res["emailable"] | ||||