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.

393 line
18 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 LWPCookieJar, LoadError
  23. import errno
  24. from getpass import getpass
  25. from os import chmod, path
  26. from platform import python_version
  27. import stat
  28. import sqlite3 as sqlite
  29. from earwigbot import __version__
  30. from earwigbot.config import config
  31. from earwigbot.wiki.exceptions import SiteNotFoundError
  32. from earwigbot.wiki.site import Site
  33. __all__ = ["SitesDBManager", "get_site", "add_site", "remove_site"]
  34. class SitesDBManager(object):
  35. """
  36. EarwigBot's Wiki Toolset: Sites Database Manager
  37. This class controls the sites.db file, which stores information about all
  38. wiki sites known to the bot. Three public methods act as bridges between
  39. the bot's config files and Site objects:
  40. get_site -- returns a Site object corresponding to a given site name
  41. add_site -- stores a site in the database, given connection info
  42. remove_site -- removes a site from the database, given its name
  43. There's usually no need to use this class directly. All public methods
  44. here are available as earwigbot.wiki.get_site(), earwigbot.wiki.add_site(),
  45. and earwigbot.wiki.remove_site(), which use a sites.db file located in the
  46. same directory as our config.yml file. Lower-level access can be achieved
  47. by importing the manager class
  48. (`from earwigbot.wiki.sitesdb import SitesDBManager`).
  49. """
  50. def __init__(self, db_file):
  51. """Set up the manager with an attribute for the sitesdb filename."""
  52. self._cookiejar = None
  53. self._sitesdb = db_file
  54. def _load_config(self):
  55. """Load the bot's config.
  56. Called by a config-requiring function, such as get_site(), when config
  57. has not been loaded. This will usually happen only if we're running
  58. code directly from Python's interpreter and not the bot itself, because
  59. bot.py and earwigbot.runner will already call these functions.
  60. """
  61. is_encrypted = config.load()
  62. if is_encrypted: # Passwords in the config file are encrypted
  63. key = getpass("Enter key to unencrypt bot passwords: ")
  64. config._decryption_key = key
  65. config.decrypt(config.wiki, "password")
  66. def _get_cookiejar(self):
  67. """Return a LWPCookieJar object loaded from our .cookies file.
  68. The same .cookies file is returned every time, located in the project
  69. root, same directory as config.yml and bot.py. If it doesn't exist, we
  70. will create the file and set it to be readable and writeable only by
  71. us. If it exists but the information inside is bogus, we'll ignore it.
  72. This is normally called by _make_site_object() (in turn called by
  73. get_site()), and the cookiejar is passed to our Site's constructor,
  74. used when it makes API queries. This way, we can easily preserve
  75. cookies between sites (e.g., for CentralAuth), making logins easier.
  76. """
  77. if self._cookiejar:
  78. return self._cookiejar
  79. cookie_file = path.join(config.root_dir, ".cookies")
  80. self._cookiejar = LWPCookieJar(cookie_file)
  81. try:
  82. self._cookiejar.load()
  83. except LoadError:
  84. pass # File contains bad data, so ignore it completely
  85. except IOError as e:
  86. if e.errno == errno.ENOENT: # "No such file or directory"
  87. # Create the file and restrict reading/writing only to the
  88. # owner, so others can't peak at our cookies:
  89. open(cookie_file, "w").close()
  90. chmod(cookie_file, stat.S_IRUSR|stat.S_IWUSR)
  91. else:
  92. raise
  93. return self._cookiejar
  94. def _create_sitesdb(self):
  95. """Initialize the sitesdb file with its three necessary tables."""
  96. script = """
  97. CREATE TABLE sites (site_name, site_project, site_lang, site_base_url,
  98. site_article_path, site_script_path);
  99. CREATE TABLE sql_data (sql_site, sql_data_key, sql_data_value);
  100. CREATE TABLE namespaces (ns_site, ns_id, ns_name, ns_is_primary_name);
  101. """
  102. with sqlite.connect(self._sitesdb) as conn:
  103. conn.executescript(script)
  104. def _load_site_from_sitesdb(self, name):
  105. """Return all information stored in the sitesdb relating to given site.
  106. The information will be returned as a tuple, containing the site's
  107. name, project, language, base URL, article path, script path, SQL
  108. connection data, and namespaces, in that order. If the site is not
  109. found in the database, SiteNotFoundError will be raised. An empty
  110. database will be created before the exception is raised if none exists.
  111. """
  112. query1 = "SELECT * FROM sites WHERE site_name = ?"
  113. query2 = "SELECT sql_data_key, sql_data_value FROM sql_data WHERE sql_site = ?"
  114. query3 = "SELECT ns_id, ns_name, ns_is_primary_name FROM namespaces WHERE ns_site = ?"
  115. error = "Site '{0}' not found in the sitesdb.".format(name)
  116. with sqlite.connect(self._sitesdb) as conn:
  117. try:
  118. site_data = conn.execute(query1, (name,)).fetchone()
  119. except sqlite.OperationalError:
  120. self._create_sitesdb()
  121. raise SiteNotFoundError(error)
  122. if not site_data:
  123. raise SiteNotFoundError(error)
  124. sql_data = conn.execute(query2, (name,)).fetchall()
  125. ns_data = conn.execute(query3, (name,)).fetchall()
  126. name, project, lang, base_url, article_path, script_path = site_data
  127. sql = dict(sql_data)
  128. namespaces = {}
  129. for ns_id, ns_name, ns_is_primary_name in ns_data:
  130. try:
  131. if ns_is_primary_name: # "Primary" name goes first in list
  132. namespaces[ns_id].insert(0, ns_name)
  133. else: # Ordering of the aliases doesn't matter
  134. namespaces[ns_id].append(ns_name)
  135. except KeyError:
  136. namespaces[ns_id] = [ns_name]
  137. return (name, project, lang, base_url, article_path, script_path, sql,
  138. namespaces)
  139. def _make_site_object(self, name):
  140. """Return a Site object associated with the site 'name' in our sitesdb.
  141. This calls _load_site_from_sitesdb(), so SiteNotFoundError will be
  142. raised if the site is not in our sitesdb.
  143. """
  144. (name, project, lang, base_url, article_path, script_path, sql,
  145. namespaces) = self._load_site_from_sitesdb(name)
  146. login = (config.wiki.get("username"), config.wiki.get("password"))
  147. cookiejar = self._get_cookiejar()
  148. user_agent = config.wiki.get("userAgent")
  149. use_https = config.wiki.get("useHTTPS", False)
  150. assert_edit = config.wiki.get("assert")
  151. maxlag = config.wiki.get("maxlag")
  152. search_config = config.wiki.get("search")
  153. if user_agent:
  154. user_agent = user_agent.replace("$1", __version__)
  155. user_agent = user_agent.replace("$2", python_version())
  156. return Site(name=name, project=project, lang=lang, base_url=base_url,
  157. article_path=article_path, script_path=script_path,
  158. sql=sql, namespaces=namespaces, login=login,
  159. cookiejar=cookiejar, user_agent=user_agent,
  160. use_https=use_https, assert_edit=assert_edit,
  161. maxlag=maxlag, search_config=search_config)
  162. def _get_site_name_from_sitesdb(self, project, lang):
  163. """Return the name of the first site with the given project and lang.
  164. If the site is not found, return None. An empty sitesdb will be created
  165. if none exists.
  166. """
  167. query = "SELECT site_name FROM site WHERE site_project = ? and site_lang = ?"
  168. with sqlite.connect(self._sitesdb) as conn:
  169. try:
  170. return conn.execute(query, (project, lang)).fetchone()
  171. except sqlite.OperationalError:
  172. self._create_sitesdb()
  173. def _add_site_to_sitesdb(self, site):
  174. """Extract relevant info from a Site object and add it to the sitesdb.
  175. Works like a reverse _load_site_from_sitesdb(); the site's project,
  176. language, base URL, article path, script path, SQL connection data, and
  177. namespaces are extracted from the site and inserted into the sites
  178. database. If the sitesdb doesn't exist, we'll create it first.
  179. """
  180. name = site.name()
  181. sites_data = (name, site.project(), site.lang(), site._base_url,
  182. site._article_path, site._script_path)
  183. sql_data = [(name, key, val) for key, val in site._sql_data.iteritems()]
  184. ns_data = []
  185. for ns_id, ns_names in site._namespaces.iteritems():
  186. ns_data.append((name, ns_id, ns_names.pop(0), True))
  187. for ns_name in ns_names:
  188. ns_data.append((name, ns_id, ns_name, False))
  189. with sqlite.connect(self._sitesdb) as conn:
  190. check_exists = "SELECT 1 FROM sites WHERE site_name = ?"
  191. try:
  192. exists = conn.execute(check_exists, (name,)).fetchone()
  193. except sqlite.OperationalError:
  194. self._create_sitesdb()
  195. else:
  196. if exists:
  197. conn.execute("DELETE FROM sites WHERE site_name = ?", (name,))
  198. conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,))
  199. conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,))
  200. conn.execute("INSERT INTO sites VALUES (?, ?, ?, ?, ?, ?)", sites_data)
  201. conn.executemany("INSERT INTO sql_data VALUES (?, ?, ?)", sql_data)
  202. conn.executemany("INSERT INTO namespaces VALUES (?, ?, ?, ?)", ns_data)
  203. def _remove_site_from_sitesdb(self, name):
  204. """Remove a site by name from the sitesdb."""
  205. with sqlite.connect(self._sitesdb) as conn:
  206. cursor = conn.execute("DELETE FROM sites WHERE site_name = ?", (name,))
  207. if cursor.rowcount == 0:
  208. return False
  209. else:
  210. conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,))
  211. conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,))
  212. return True
  213. def get_site(self, name=None, project=None, lang=None):
  214. """Return a Site instance based on information from the sitesdb.
  215. With no arguments, return the default site as specified by our config
  216. file. This is config.wiki["defaultSite"].
  217. With 'name' specified, return the site with that name. This is
  218. equivalent to the site's 'wikiid' in the API, like 'enwiki'.
  219. With 'project' and 'lang' specified, return the site whose project and
  220. language match these values. If there are multiple sites with the same
  221. values (unlikely), this is not a reliable way of loading a site. Call
  222. the function with an explicit 'name' in that case.
  223. We will attempt to login to the site automatically using
  224. config.wiki["username"] and config.wiki["password"] if both are
  225. defined.
  226. Specifying a project without a lang or a lang without a project will
  227. raise TypeError. If all three args are specified, 'name' will be first
  228. tried, then 'project' and 'lang' if 'name' doesn't work. If a site
  229. cannot be found in the sitesdb, SiteNotFoundError will be raised. An
  230. empty sitesdb will be created if none is found.
  231. """
  232. if not config.is_loaded():
  233. self._load_config()
  234. # Someone specified a project without a lang, or vice versa:
  235. if (project and not lang) or (not project and lang):
  236. e = "Keyword arguments 'lang' and 'project' must be specified together."
  237. raise TypeError(e)
  238. # No args given, so return our default site:
  239. if not name and not project and not lang:
  240. try:
  241. default = config.wiki["defaultSite"]
  242. except KeyError:
  243. e = "Default site is not specified in config."
  244. raise SiteNotFoundError(e)
  245. return self._make_site_object(default)
  246. # Name arg given, but don't look at others unless `name` isn't found:
  247. if name:
  248. try:
  249. return self._make_site_object(name)
  250. except SiteNotFoundError:
  251. if project and lang:
  252. name = self._get_site_name_from_sitesdb(project, lang)
  253. if name:
  254. return self._make_site_object(name)
  255. raise
  256. # If we end up here, then project and lang are the only args given:
  257. name = self._get_site_name_from_sitesdb(project, lang)
  258. if name:
  259. return self._make_site_object(name)
  260. e = "Site '{0}:{1}' not found in the sitesdb.".format(project, lang)
  261. raise SiteNotFoundError(e)
  262. def add_site(self, project=None, lang=None, base_url=None,
  263. script_path="/w", sql=None):
  264. """Add a site to the sitesdb so it can be retrieved with get_site().
  265. If only a project and a lang are given, we'll guess the base_url as
  266. "//{lang}.{project}.org" (which is protocol-relative, becoming 'https'
  267. if 'useHTTPS' is True in config otherwise 'http'). If this is wrong,
  268. provide the correct base_url as an argument (in which case project and
  269. lang are ignored). Most wikis use "/w" as the script path (meaning the
  270. API is located at "{base_url}{script_path}/api.php" ->
  271. "//{lang}.{project}.org/w/api.php"), so this is the default. If your
  272. wiki is different, provide the script_path as an argument. The only
  273. other argument to Site() that we can't get from config files or by
  274. querying the wiki itself is SQL connection info, so provide a dict of
  275. kwargs as `sql` and Site will pass it to oursql.connect(**sql),
  276. allowing you to make queries with site.sql_query().
  277. Returns True if the site was added successfully or False if the site is
  278. already in our sitesdb (this can be done purposefully to update old
  279. site info). Raises SiteNotFoundError if not enough information has
  280. been provided to identify the site (e.g. a project but not a lang).
  281. """
  282. if not config.is_loaded():
  283. self._load_config()
  284. if not base_url:
  285. if not project or not lang:
  286. e = "Without a base_url, both a project and a lang must be given."
  287. raise SiteNotFoundError(e)
  288. base_url = "//{0}.{1}.org".format(lang, project)
  289. login = (config.wiki.get("username"), config.wiki.get("password"))
  290. cookiejar = self._get_cookiejar()
  291. user_agent = config.wiki.get("userAgent")
  292. use_https = config.wiki.get("useHTTPS", False)
  293. assert_edit = config.wiki.get("assert")
  294. maxlag = config.wiki.get("maxlag")
  295. search_config = config.wiki.get("search")
  296. # Create a temp Site object to log in and load the other attributes:
  297. site = Site(base_url=base_url, script_path=script_path, sql=sql,
  298. login=login, cookiejar=cookiejar, user_agent=user_agent,
  299. use_https=use_https, assert_edit=assert_edit,
  300. maxlag=maxlag, search_config=search_config)
  301. self._add_site_to_sitesdb(site)
  302. return site
  303. def remove_site(self, name=None, project=None, lang=None):
  304. """Remove a site from the sitesdb.
  305. Returns True if the site was removed successfully or False if the site
  306. was not in our sitesdb originally. If all three args (name, project,
  307. and lang) are given, we'll first try 'name' and then try the latter two
  308. if 'name' wasn't found in the database. Raises TypeError if a project
  309. was given but not a language, or vice versa. Will create an empty
  310. sitesdb if none was found.
  311. """
  312. if not config.is_loaded():
  313. self._load_config()
  314. # Someone specified a project without a lang, or vice versa:
  315. if (project and not lang) or (not project and lang):
  316. e = "Keyword arguments 'lang' and 'project' must be specified together."
  317. raise TypeError(e)
  318. if name:
  319. was_removed = self._remove_site_from_sitesdb(name)
  320. if not was_removed:
  321. if project and lang:
  322. name = self._get_site_name_from_sitesdb(project, lang)
  323. if name:
  324. return self._remove_site_from_sitesdb(name)
  325. return was_removed
  326. if project and lang:
  327. name = self._get_site_name_from_sitesdb(project, lang)
  328. if name:
  329. return self._remove_site_from_sitesdb(name)
  330. return False
  331. _root = path.split(path.split(path.dirname(path.abspath(__file__)))[0])[0]
  332. _dbfile = path.join(_root, "sites.db")
  333. _manager = SitesDBManager(_dbfile)
  334. del _root, _dbfile
  335. get_site = _manager.get_site
  336. add_site = _manager.add_site
  337. remove_site = _manager.remove_site