@@ -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"] | ||||