A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

696 lines
29 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining a copy
  6. # of this software and associated documentation files (the "Software"), to deal
  7. # in the Software without restriction, including without limitation the rights
  8. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. # copies of the Software, and to permit persons to whom the Software is
  10. # furnished to do so, subject to the following conditions:
  11. #
  12. # The above copyright notice and this permission notice shall be included in
  13. # all copies or substantial portions of the Software.
  14. #
  15. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21. # SOFTWARE.
  22. from cookielib import CookieJar
  23. from gzip import GzipFile
  24. from json import loads
  25. from logging import getLogger, NullHandler
  26. from os.path import expanduser
  27. from re import escape as re_escape, match as re_match
  28. from StringIO import StringIO
  29. from threading import Lock
  30. from time import sleep, time
  31. from urllib import quote_plus
  32. from urllib2 import build_opener, HTTPCookieProcessor, URLError
  33. from urlparse import urlparse
  34. try:
  35. import oursql
  36. except ImportError:
  37. oursql = None
  38. from earwigbot import exceptions
  39. from earwigbot.wiki import constants
  40. from earwigbot.wiki.category import Category
  41. from earwigbot.wiki.page import Page
  42. from earwigbot.wiki.user import User
  43. __all__ = ["Site"]
  44. class Site(object):
  45. """
  46. EarwigBot's Wiki Toolset: Site Class
  47. Represents a Site, with support for API queries and returning Pages, Users,
  48. and Categories. The constructor takes a bunch of arguments and you probably
  49. won't need to call it directly, rather tools.get_site() for returning Site
  50. instances, tools.add_site() for adding new ones to config, and
  51. tools.del_site() for removing old ones from config, should suffice.
  52. Attributes:
  53. name -- the site's name (or "wikiid"), like "enwiki"
  54. project -- the site's project name, like "wikipedia"
  55. lang -- the site's language code, like "en"
  56. domain -- the site's web domain, like "en.wikipedia.org"
  57. Public methods:
  58. api_query -- does an API query with the given kwargs as params
  59. sql_query -- does an SQL query and yields its results
  60. get_replag -- returns the estimated database replication lag
  61. namespace_id_to_name -- given a namespace ID, returns associated name(s)
  62. namespace_name_to_id -- given a namespace name, returns the associated ID
  63. get_page -- returns a Page object for the given title
  64. get_category -- returns a Category object for the given title
  65. get_user -- returns a User object for the given username
  66. """
  67. def __init__(self, name=None, project=None, lang=None, base_url=None,
  68. article_path=None, script_path=None, sql=None,
  69. namespaces=None, login=(None, None), cookiejar=None,
  70. user_agent=None, use_https=False, assert_edit=None,
  71. maxlag=None, wait_between_queries=5, logger=None,
  72. search_config=(None, None)):
  73. """Constructor for new Site instances.
  74. This probably isn't necessary to call yourself unless you're building a
  75. Site that's not in your config and you don't want to add it - normally
  76. all you need is tools.get_site(name), which creates the Site for you
  77. based on your config file and the sites database. We accept a bunch of
  78. kwargs, but the only ones you really "need" are `base_url` and
  79. `script_path` - this is enough to figure out an API url. `login`, a
  80. tuple of (username, password), is highly recommended. `cookiejar` will
  81. be used to store cookies, and we'll use a normal CookieJar if none is
  82. given.
  83. First, we'll store the given arguments as attributes, then set up our
  84. URL opener. We'll load any of the attributes that weren't given from
  85. the API, and then log in if a username/pass was given and we aren't
  86. already logged in.
  87. """
  88. # Attributes referring to site information, filled in by an API query
  89. # if they are missing (and an API url can be determined):
  90. self._name = name
  91. self._project = project
  92. self._lang = lang
  93. self._base_url = base_url
  94. self._article_path = article_path
  95. self._script_path = script_path
  96. self._namespaces = namespaces
  97. # Attributes used for API queries:
  98. self._use_https = use_https
  99. self._assert_edit = assert_edit
  100. self._maxlag = maxlag
  101. self._wait_between_queries = wait_between_queries
  102. self._max_retries = 5
  103. self._last_query_time = 0
  104. # Attributes used for SQL queries:
  105. self._sql_data = sql
  106. self._sql_conn = None
  107. self._sql_lock = Lock()
  108. # Attribute used in copyright violation checks (see CopyrightMixin):
  109. self._search_config = search_config
  110. # Set up cookiejar and URL opener for making API queries:
  111. if cookiejar:
  112. self._cookiejar = cookiejar
  113. else:
  114. self._cookiejar = CookieJar()
  115. if not user_agent:
  116. user_agent = constants.USER_AGENT # Set default UA
  117. self._opener = build_opener(HTTPCookieProcessor(self._cookiejar))
  118. self._opener.addheaders = [("User-Agent", user_agent),
  119. ("Accept-Encoding", "gzip")]
  120. # Get all of the above attributes that were not specified as arguments:
  121. self._load_attributes()
  122. # Set up our internal logger:
  123. if logger:
  124. self._logger = logger
  125. else: # Just set up a null logger to eat up our messages:
  126. self._logger = getLogger("earwigbot.wiki")
  127. self._logger.addHandler(NullHandler())
  128. # If we have a name/pass and the API says we're not logged in, log in:
  129. self._login_info = name, password = login
  130. if name and password:
  131. logged_in_as = self._get_username_from_cookies()
  132. if not logged_in_as or name != logged_in_as:
  133. self._login(login)
  134. def __repr__(self):
  135. """Returns the canonical string representation of the Site."""
  136. res = ", ".join((
  137. "Site(name={_name!r}", "project={_project!r}", "lang={_lang!r}",
  138. "base_url={_base_url!r}", "article_path={_article_path!r}",
  139. "script_path={_script_path!r}", "use_https={_use_https!r}",
  140. "assert_edit={_assert_edit!r}", "maxlag={_maxlag!r}",
  141. "sql={_sql_data!r}", "login={0}", "user_agent={2!r}",
  142. "cookiejar={1})"))
  143. name, password = self._login_info
  144. login = "({0}, {1})".format(repr(name), "hidden" if password else None)
  145. cookies = self._cookiejar.__class__.__name__
  146. try:
  147. cookies += "({0!r})".format(self._cookiejar.filename)
  148. except AttributeError:
  149. cookies += "()"
  150. agent = self._opener.addheaders[0][1]
  151. return res.format(login, cookies, agent, **self.__dict__)
  152. def __str__(self):
  153. """Returns a nice string representation of the Site."""
  154. res = "<Site {0} ({1}:{2}) at {3}>"
  155. return res.format(self.name(), self.project(), self.lang(),
  156. self.domain())
  157. def _urlencode_utf8(self, params):
  158. """Implement urllib.urlencode(params) with support for unicode input."""
  159. enc = lambda s: s.encode("utf8") if isinstance(s, unicode) else str(s)
  160. args = []
  161. for key, val in params.iteritems():
  162. key = quote_plus(enc(key))
  163. val = quote_plus(enc(val))
  164. args.append(key + "=" + val)
  165. return "&".join(args)
  166. def _api_query(self, params, tries=0, wait=5):
  167. """Do an API query with `params` as a dict of parameters.
  168. This will first attempt to construct an API url from self._base_url and
  169. self._script_path. We need both of these, or else we'll raise
  170. SiteAPIError. If self._base_url is protocol-relative (introduced in
  171. MediaWiki 1.18), we'll choose HTTPS if self._user_https is True,
  172. otherwise HTTP.
  173. We'll encode the given params, adding format=json along the way, as
  174. well as &assert= and &maxlag= based on self._assert_edit and _maxlag.
  175. Additionally, we'll sleep a bit if the last query was made less than
  176. self._wait_between_queries seconds ago. The request is made through
  177. self._opener, which has cookie support (self._cookiejar), a User-Agent
  178. (wiki.constants.USER_AGENT), and Accept-Encoding set to "gzip".
  179. Assuming everything went well, we'll gunzip the data (if compressed),
  180. load it as a JSON object, and return it.
  181. If our request failed for some reason, we'll raise SiteAPIError with
  182. details. If that reason was due to maxlag, we'll sleep for a bit and
  183. then repeat the query until we exceed self._max_retries.
  184. There's helpful MediaWiki API documentation at
  185. <http://www.mediawiki.org/wiki/API>.
  186. """
  187. since_last_query = time() - self._last_query_time # Throttling support
  188. if since_last_query < self._wait_between_queries:
  189. wait_time = self._wait_between_queries - since_last_query
  190. log = "Throttled: waiting {0} seconds".format(round(wait_time, 2))
  191. self._logger.debug(log)
  192. sleep(wait_time)
  193. self._last_query_time = time()
  194. url, data = self._build_api_query(params)
  195. self._logger.debug("{0} -> {1}".format(url, data))
  196. try:
  197. response = self._opener.open(url, data)
  198. except URLError as error:
  199. if hasattr(error, "reason"):
  200. e = "API query failed: {0}.".format(error.reason)
  201. elif hasattr(error, "code"):
  202. e = "API query failed: got an error code of {0}."
  203. e = e.format(error.code)
  204. else:
  205. e = "API query failed."
  206. raise exceptions.SiteAPIError(e)
  207. result = response.read()
  208. if response.headers.get("Content-Encoding") == "gzip":
  209. stream = StringIO(result)
  210. gzipper = GzipFile(fileobj=stream)
  211. result = gzipper.read()
  212. return self._handle_api_query_result(result, params, tries, wait)
  213. def _build_api_query(self, params):
  214. """Given API query params, return the URL to query and POST data."""
  215. if not self._base_url or self._script_path is None:
  216. e = "Tried to do an API query, but no API URL is known."
  217. raise exceptions.SiteAPIError(e)
  218. base_url = self._base_url
  219. if base_url.startswith("//"): # Protocol-relative URLs from 1.18
  220. if self._use_https:
  221. base_url = "https:" + base_url
  222. else:
  223. base_url = "http:" + base_url
  224. url = ''.join((base_url, self._script_path, "/api.php"))
  225. params["format"] = "json" # This is the only format we understand
  226. if self._assert_edit: # If requested, ensure that we're logged in
  227. params["assert"] = self._assert_edit
  228. if self._maxlag: # If requested, don't overload the servers
  229. params["maxlag"] = self._maxlag
  230. data = self._urlencode_utf8(params)
  231. return url, data
  232. def _handle_api_query_result(self, result, params, tries, wait):
  233. """Given the result of an API query, attempt to return useful data."""
  234. try:
  235. res = loads(result) # Try to parse as a JSON object
  236. except ValueError:
  237. e = "API query failed: JSON could not be decoded."
  238. raise exceptions.SiteAPIError(e)
  239. try:
  240. code = res["error"]["code"]
  241. info = res["error"]["info"]
  242. except (TypeError, KeyError): # Having these keys indicates a problem
  243. return res # All is well; return the decoded JSON
  244. if code == "maxlag": # We've been throttled by the server
  245. if tries >= self._max_retries:
  246. e = "Maximum number of retries reached ({0})."
  247. raise exceptions.SiteAPIError(e.format(self._max_retries))
  248. tries += 1
  249. msg = 'Server says "{0}"; retrying in {1} seconds ({2}/{3})'
  250. self._logger.info(msg.format(info, wait, tries, self._max_retries))
  251. sleep(wait)
  252. return self._api_query(params, tries=tries, wait=wait*3)
  253. else: # Some unknown error occurred
  254. e = 'API query failed: got error "{0}"; server says: "{1}".'
  255. error = earwigbot.SiteAPIError(e.format(code, info))
  256. error.code, error.info = code, info
  257. raise error
  258. def _load_attributes(self, force=False):
  259. """Load data about our Site from the API.
  260. This function is called by __init__() when one of the site attributes
  261. was not given as a keyword argument. We'll do an API query to get the
  262. missing data, but only if there actually *is* missing data.
  263. Additionally, you can call this with `force=True` to forcibly reload
  264. all attributes.
  265. """
  266. # All attributes to be loaded, except _namespaces, which is a special
  267. # case because it requires additional params in the API query:
  268. attrs = [self._name, self._project, self._lang, self._base_url,
  269. self._article_path, self._script_path]
  270. params = {"action": "query", "meta": "siteinfo"}
  271. if not self._namespaces or force:
  272. params["siprop"] = "general|namespaces|namespacealiases"
  273. result = self._api_query(params)
  274. self._load_namespaces(result)
  275. elif all(attrs): # Everything is already specified and we're not told
  276. return # to force a reload, so do nothing
  277. else: # We're only loading attributes other than _namespaces
  278. params["siprop"] = "general"
  279. result = self._api_query(params)
  280. res = result["query"]["general"]
  281. self._name = res["wikiid"]
  282. self._project = res["sitename"].lower()
  283. self._lang = res["lang"]
  284. self._base_url = res["server"]
  285. self._article_path = res["articlepath"]
  286. self._script_path = res["scriptpath"]
  287. def _load_namespaces(self, result):
  288. """Fill self._namespaces with a dict of namespace IDs and names.
  289. Called by _load_attributes() with API data as `result` when
  290. self._namespaces was not given as an kwarg to __init__().
  291. """
  292. self._namespaces = {}
  293. for namespace in result["query"]["namespaces"].values():
  294. ns_id = namespace["id"]
  295. name = namespace["*"]
  296. try:
  297. canonical = namespace["canonical"]
  298. except KeyError:
  299. self._namespaces[ns_id] = [name]
  300. else:
  301. if name != canonical:
  302. self._namespaces[ns_id] = [name, canonical]
  303. else:
  304. self._namespaces[ns_id] = [name]
  305. for namespace in result["query"]["namespacealiases"]:
  306. ns_id = namespace["id"]
  307. alias = namespace["*"]
  308. self._namespaces[ns_id].append(alias)
  309. def _get_cookie(self, name, domain):
  310. """Return the named cookie unless it is expired or doesn't exist."""
  311. for cookie in self._cookiejar:
  312. if cookie.name == name and cookie.domain == domain:
  313. if cookie.is_expired():
  314. break
  315. return cookie
  316. def _get_username_from_cookies(self):
  317. """Try to return our username based solely on cookies.
  318. First, we'll look for a cookie named self._name + "Token", like
  319. "enwikiToken". If it exists and isn't expired, we'll assume it's valid
  320. and try to return the value of the cookie self._name + "UserName" (like
  321. "enwikiUserName"). This should work fine on wikis without single-user
  322. login.
  323. If `enwikiToken` doesn't exist, we'll try to find a cookie named
  324. `centralauth_Token`. If this exists and is not expired, we'll try to
  325. return the value of `centralauth_User`.
  326. If we didn't get any matches, we'll return None. Our goal here isn't to
  327. return the most likely username, or what we *want* our username to be
  328. (for that, we'd do self._login_info[0]), but rather to get our current
  329. username without an unnecessary ?action=query&meta=userinfo API query.
  330. """
  331. domain = self.domain()
  332. name = ''.join((self._name, "Token"))
  333. cookie = self._get_cookie(name, domain)
  334. if cookie:
  335. name = ''.join((self._name, "UserName"))
  336. user_name = self._get_cookie(name, domain)
  337. if user_name:
  338. return user_name.value
  339. name = "centralauth_Token"
  340. for cookie in self._cookiejar:
  341. if not cookie.domain_initial_dot or cookie.is_expired():
  342. continue
  343. if cookie.name != name:
  344. continue
  345. # Build a regex that will match domains this cookie affects:
  346. search = ''.join(("(.*?)", re_escape(cookie.domain)))
  347. if re_match(search, domain): # Test it against our site
  348. user_name = self._get_cookie("centralauth_User", cookie.domain)
  349. if user_name:
  350. return user_name.value
  351. def _get_username_from_api(self):
  352. """Do a simple API query to get our username and return it.
  353. This is a reliable way to make sure we are actually logged in, because
  354. it doesn't deal with annoying cookie logic, but it results in an API
  355. query that is unnecessary in some cases.
  356. Called by _get_username() (in turn called by get_user() with no
  357. username argument) when cookie lookup fails, probably indicating that
  358. we are logged out.
  359. """
  360. params = {"action": "query", "meta": "userinfo"}
  361. result = self._api_query(params)
  362. return result["query"]["userinfo"]["name"]
  363. def _get_username(self):
  364. """Return the name of the current user, whether logged in or not.
  365. First, we'll try to deduce it solely from cookies, to avoid an
  366. unnecessary API query. For the cookie-detection method, see
  367. _get_username_from_cookies()'s docs.
  368. If our username isn't in cookies, then we're probably not logged in, or
  369. something fishy is going on (like forced logout). In this case, do a
  370. single API query for our username (or IP address) and return that.
  371. """
  372. name = self._get_username_from_cookies()
  373. if name:
  374. return name
  375. return self._get_username_from_api()
  376. def _save_cookiejar(self):
  377. """Try to save our cookiejar after doing a (normal) login or logout.
  378. Calls the standard .save() method with no filename. Don't fret if our
  379. cookiejar doesn't support saving (CookieJar raises AttributeError,
  380. FileCookieJar raises NotImplementedError) or no default filename was
  381. given (LWPCookieJar and MozillaCookieJar raise ValueError).
  382. """
  383. try:
  384. self._cookiejar.save()
  385. except (AttributeError, NotImplementedError, ValueError):
  386. pass
  387. def _login(self, login, token=None, attempt=0):
  388. """Safely login through the API.
  389. Normally, this is called by __init__() if a username and password have
  390. been provided and no valid login cookies were found. The only other
  391. time it needs to be called is when those cookies expire, which is done
  392. automatically by api_query() if a query fails.
  393. Recent versions of MediaWiki's API have fixed a CSRF vulnerability,
  394. requiring login to be done in two separate requests. If the response
  395. from from our initial request is "NeedToken", we'll do another one with
  396. the token. If login is successful, we'll try to save our cookiejar.
  397. Raises LoginError on login errors (duh), like bad passwords and
  398. nonexistent usernames.
  399. `login` is a (username, password) tuple. `token` is the token returned
  400. from our first request, and `attempt` is to prevent getting stuck in a
  401. loop if MediaWiki isn't acting right.
  402. """
  403. name, password = login
  404. params = {"action": "login", "lgname": name, "lgpassword": password}
  405. if token:
  406. params["lgtoken"] = token
  407. result = self._api_query(params)
  408. res = result["login"]["result"]
  409. if res == "Success":
  410. self._save_cookiejar()
  411. elif res == "NeedToken" and attempt == 0:
  412. token = result["login"]["token"]
  413. return self._login(login, token, attempt=1)
  414. else:
  415. if res == "Illegal":
  416. e = "The provided username is illegal."
  417. elif res == "NotExists":
  418. e = "The provided username does not exist."
  419. elif res == "EmptyPass":
  420. e = "No password was given."
  421. elif res == "WrongPass" or res == "WrongPluginPass":
  422. e = "The given password is incorrect."
  423. else:
  424. e = "Couldn't login; server says '{0}'.".format(res)
  425. raise exceptions.LoginError(e)
  426. def _logout(self):
  427. """Safely logout through the API.
  428. We'll do a simple API request (api.php?action=logout), clear our
  429. cookiejar (which probably contains now-invalidated cookies) and try to
  430. save it, if it supports that sort of thing.
  431. """
  432. params = {"action": "logout"}
  433. self._api_query(params)
  434. self._cookiejar.clear()
  435. self._save_cookiejar()
  436. def _sql_connect(self, **kwargs):
  437. """Attempt to establish a connection with this site's SQL database.
  438. oursql.connect() will be called with self._sql_data as its kwargs.
  439. Any kwargs given to this function will be passed to connect() and will
  440. have precedence over the config file.
  441. Will raise SQLError() if the module "oursql" is not available. oursql
  442. may raise its own exceptions (e.g. oursql.InterfaceError) if it cannot
  443. establish a connection.
  444. """
  445. if not oursql:
  446. e = "Module 'oursql' is required for SQL queries."
  447. raise exceptions.SQLError(e)
  448. args = self._sql_data
  449. for key, value in kwargs.iteritems():
  450. args[key] = value
  451. if "read_default_file" not in args and "user" not in args and "passwd" not in args:
  452. args["read_default_file"] = expanduser("~/.my.cnf")
  453. if "autoping" not in args:
  454. args["autoping"] = True
  455. if "autoreconnect" not in args:
  456. args["autoreconnect"] = True
  457. self._sql_conn = oursql.connect(**args)
  458. def name(self):
  459. """Returns the Site's name (or "wikiid" in the API), like "enwiki"."""
  460. return self._name
  461. def project(self):
  462. """Returns the Site's project name in lowercase, like "wikipedia"."""
  463. return self._project
  464. def lang(self):
  465. """Returns the Site's language code, like "en" or "es"."""
  466. return self._lang
  467. def domain(self):
  468. """Returns the Site's web domain, like "en.wikipedia.org"."""
  469. return urlparse(self._base_url).netloc
  470. def api_query(self, **kwargs):
  471. """Do an API query with `kwargs` as the parameters.
  472. See _api_query()'s documentation for details.
  473. """
  474. return self._api_query(kwargs)
  475. def sql_query(self, query, params=(), plain_query=False, dict_cursor=False,
  476. cursor_class=None, show_table=False):
  477. """Do an SQL query and yield its results.
  478. If `plain_query` is True, we will force an unparameterized query.
  479. Specifying both params and plain_query will cause an error.
  480. If `dict_cursor` is True, we will use oursql.DictCursor as our cursor,
  481. otherwise the default oursql.Cursor. If `cursor_class` is given, it
  482. will override this option.
  483. If `show_table` is True, the name of the table will be prepended to the
  484. name of the column. This will mainly affect a DictCursor.
  485. Example:
  486. >>> query = "SELECT user_id, user_registration FROM user WHERE user_name = ?"
  487. >>> params = ("The Earwig",)
  488. >>> result1 = site.sql_query(query, params)
  489. >>> result2 = site.sql_query(query, params, dict_cursor=True)
  490. >>> for row in result1: print row
  491. (7418060L, '20080703215134')
  492. >>> for row in result2: print row
  493. {'user_id': 7418060L, 'user_registration': '20080703215134'}
  494. See _sql_connect() for information on how a connection is acquired.
  495. <http://packages.python.org/oursql> has helpful documentation on the
  496. oursql module.
  497. This may raise SQLError() or one of oursql's exceptions
  498. (oursql.ProgrammingError, oursql.InterfaceError, ...) if there were
  499. problems with the query.
  500. """
  501. if not cursor_class:
  502. if dict_cursor:
  503. cursor_class = oursql.DictCursor
  504. else:
  505. cursor_class = oursql.Cursor
  506. klass = cursor_class
  507. with self._sql_lock:
  508. if not self._sql_conn:
  509. self._sql_connect()
  510. with self._sql_conn.cursor(klass, show_table=show_table) as cur:
  511. cur.execute(query, params, plain_query)
  512. for result in cur:
  513. yield result
  514. def get_replag(self):
  515. """Return the estimated database replication lag in seconds.
  516. Requires SQL access. This function only makes sense on a replicated
  517. database (e.g. the Wikimedia Toolserver) and on a wiki that receives a
  518. large number of edits (ideally, at least one per second), or the result
  519. may be larger than expected.
  520. """
  521. query = """SELECT UNIX_TIMESTAMP() - UNIX_TIMESTAMP(rc_timestamp) FROM
  522. recentchanges ORDER BY rc_timestamp DESC LIMIT 1"""
  523. result = list(self.sql_query(query))
  524. return result[0][0]
  525. def namespace_id_to_name(self, ns_id, all=False):
  526. """Given a namespace ID, returns associated namespace names.
  527. If all is False (default), we'll return the first name in the list,
  528. which is usually the localized version. Otherwise, we'll return the
  529. entire list, which includes the canonical name.
  530. For example, returns u"Wikipedia" if ns_id=4 and all=False on enwiki;
  531. returns [u"Wikipedia", u"Project", u"WP"] if ns_id=4 and all=True.
  532. Raises NamespaceNotFoundError if the ID is not found.
  533. """
  534. try:
  535. if all:
  536. return self._namespaces[ns_id]
  537. else:
  538. return self._namespaces[ns_id][0]
  539. except KeyError:
  540. e = "There is no namespace with id {0}.".format(ns_id)
  541. raise exceptions.NamespaceNotFoundError(e)
  542. def namespace_name_to_id(self, name):
  543. """Given a namespace name, returns the associated ID.
  544. Like namespace_id_to_name(), but reversed. Case is ignored, because
  545. namespaces are assumed to be case-insensitive.
  546. Raises NamespaceNotFoundError if the name is not found.
  547. """
  548. lname = name.lower()
  549. for ns_id, names in self._namespaces.items():
  550. lnames = [n.lower() for n in names] # Be case-insensitive
  551. if lname in lnames:
  552. return ns_id
  553. e = "There is no namespace with name '{0}'.".format(name)
  554. raise exceptions.NamespaceNotFoundError(e)
  555. def get_page(self, title, follow_redirects=False):
  556. """Returns a Page object for the given title (pagename).
  557. Will return a Category object instead if the given title is in the
  558. category namespace. As Category is a subclass of Page, this should not
  559. cause problems.
  560. Note that this doesn't do any direct checks for existence or
  561. redirect-following - Page's methods provide that.
  562. """
  563. prefixes = self.namespace_id_to_name(constants.NS_CATEGORY, all=True)
  564. prefix = title.split(":", 1)[0]
  565. if prefix != title: # Avoid a page that is simply "Category"
  566. if prefix in prefixes:
  567. return Category(self, title, follow_redirects)
  568. return Page(self, title, follow_redirects)
  569. def get_category(self, catname, follow_redirects=False):
  570. """Returns a Category object for the given category name.
  571. `catname` should be given *without* a namespace prefix. This method is
  572. really just shorthand for get_page("Category:" + catname).
  573. """
  574. prefix = self.namespace_id_to_name(constants.NS_CATEGORY)
  575. pagename = ':'.join((prefix, catname))
  576. return Category(self, pagename, follow_redirects)
  577. def get_user(self, username=None):
  578. """Returns a User object for the given username.
  579. If `username` is left as None, then a User object representing the
  580. currently logged-in (or anonymous!) user is returned.
  581. """
  582. if not username:
  583. username = self._get_username()
  584. return User(self, username)