A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

247 行
9.8 KiB

  1. #! /usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. #
  4. # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
  5. #
  6. # Permission is hereby granted, free of charge, to any person obtaining a copy
  7. # of this software and associated documentation files (the "Software"), to deal
  8. # in the Software without restriction, including without limitation the rights
  9. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. # copies of the Software, and to permit persons to whom the Software is
  11. # furnished to do so, subject to the following conditions:
  12. #
  13. # The above copyright notice and this permission notice shall be included in
  14. # all copies or substantial portions of the Software.
  15. #
  16. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22. # SOFTWARE.
  23. import imp
  24. from os import listdir, path
  25. from re import sub
  26. from threading import RLock, Thread
  27. from time import gmtime, strftime
  28. from earwigbot.commands import Command
  29. from earwigbot.tasks import Task
  30. __all__ = ["CommandManager", "TaskManager"]
  31. class _ResourceManager(object):
  32. """
  33. **EarwigBot: Resource Manager**
  34. Resources are essentially objects dynamically loaded by the bot, both
  35. packaged with it (built-in resources) and created by users (plugins, aka
  36. custom resources). Currently, the only two types of resources are IRC
  37. commands and bot tasks. These are both loaded from two locations: the
  38. :py:mod:`earwigbot.commands` and :py:mod:`earwigbot.tasks packages`, and
  39. the :file:`commands/` and :file:`tasks/` directories within the bot's
  40. working directory.
  41. This class handles the low-level tasks of (re)loading resources via
  42. :py:meth:`load`, retrieving specific resources via :py:meth:`get`, and
  43. iterating over all resources via :py:meth:`__iter__`.
  44. """
  45. def __init__(self, bot, name, base):
  46. self.bot = bot
  47. self.logger = bot.logger.getChild(name)
  48. self._resources = {}
  49. self._resource_name = name # e.g. "commands" or "tasks"
  50. self._resource_base = base # e.g. Command or Task
  51. self._resource_access_lock = RLock()
  52. def __repr__(self):
  53. """Return the canonical string representation of the manager."""
  54. res = "{0}(bot={1!r}, name={2!r}, base={3!r})"
  55. return res.format(self.__class__.__name__, self.bot,
  56. self._resource_name, self._resource_base)
  57. def __str__(self):
  58. """Return a nice string representation of the manager."""
  59. return "<{0} of {1}>".format(self.__class__.__name__, self.bot)
  60. def __iter__(self):
  61. with self.lock:
  62. for resource in self._resources.itervalues():
  63. yield resource
  64. def _load_resource(self, name, path, klass):
  65. """Instantiate a resource class and add it to the dictionary."""
  66. res_type = self._resource_name[:-1] # e.g. "command" or "task"
  67. try:
  68. resource = klass(self.bot) # Create instance of resource
  69. except Exception:
  70. e = "Error instantiating {0} class in {1} (from {2})"
  71. self.logger.exception(e.format(res_type, name, path))
  72. else:
  73. self._resources[resource.name] = resource
  74. self.logger.debug("Loaded {0} {1}".format(res_type, resource.name))
  75. def _load_module(self, name, path):
  76. """Load a specific resource from a module, identified by name and path.
  77. We'll first try to import it using imp magic, and if that works, make
  78. instances of any classes inside that are subclasses of the base
  79. (:py:attr:`self._resource_base <_resource_base>`), add them to the
  80. resources dictionary with :py:meth:`self._load_resource()
  81. <_load_resource>`, and finally log the addition. Any problems along
  82. the way will either be ignored or logged.
  83. """
  84. f, path, desc = imp.find_module(name, [path])
  85. try:
  86. module = imp.load_module(name, f, path, desc)
  87. except Exception:
  88. e = "Couldn't load module {0} (from {1})"
  89. self.logger.exception(e.format(name, path))
  90. return
  91. finally:
  92. f.close()
  93. for obj in vars(module).values():
  94. if type(obj) is type:
  95. isresource = issubclass(obj, self._resource_base)
  96. if isresource and not obj is self._resource_base:
  97. self._load_resource(name, path, obj)
  98. def _load_directory(self, dir):
  99. """Load all valid resources in a given directory."""
  100. self.logger.debug("Loading directory {0}".format(dir))
  101. processed = []
  102. for name in listdir(dir):
  103. if not name.endswith(".py") and not name.endswith(".pyc"):
  104. continue
  105. if name.startswith("_") or name.startswith("."):
  106. continue
  107. modname = sub("\.pyc?$", "", name) # Remove extension
  108. if modname not in processed:
  109. self._load_module(modname, dir)
  110. processed.append(modname)
  111. @property
  112. def lock(self):
  113. """The resource access/modify lock."""
  114. return self._resource_access_lock
  115. def load(self):
  116. """Load (or reload) all valid resources into :py:attr:`_resources`."""
  117. name = self._resource_name # e.g. "commands" or "tasks"
  118. with self.lock:
  119. self._resources.clear()
  120. builtin_dir = path.join(path.dirname(__file__), name)
  121. plugins_dir = path.join(self.bot.config.root_dir, name)
  122. self._load_directory(builtin_dir) # Built-in resources
  123. self._load_directory(plugins_dir) # Custom resources, aka plugins
  124. msg = "Loaded {0} {1}: {2}"
  125. resources = ", ".join(self._resources.keys())
  126. self.logger.info(msg.format(len(self._resources), name, resources))
  127. def get(self, key):
  128. """Return the class instance associated with a certain resource.
  129. Will raise :py:exc:`KeyError` if the resource (a command or task) is
  130. not found.
  131. """
  132. with self.lock:
  133. return self._resources[key]
  134. class CommandManager(_ResourceManager):
  135. """
  136. Manages (i.e., loads, reloads, and calls) IRC commands.
  137. """
  138. def __init__(self, bot):
  139. super(CommandManager, self).__init__(bot, "commands", Command)
  140. def _wrap_check(self, command, data):
  141. """Check whether a command should be called, catching errors."""
  142. try:
  143. return command.check(data)
  144. except Exception:
  145. e = "Error checking command '{0}' with data: {1}:"
  146. self.logger.exception(e.format(command.name, data))
  147. def _wrap_process(self, command, data):
  148. """process() the message, catching and reporting any errors."""
  149. try:
  150. command.process(data)
  151. except Exception:
  152. e = "Error executing command '{0}':"
  153. self.logger.exception(e.format(command.name))
  154. def call(self, hook, data):
  155. """Respond to a hook type and a :py:class:`Data` object."""
  156. for command in self:
  157. if hook in command.hooks and self._wrap_check(command, data):
  158. thread = Thread(target=self._wrap_process,
  159. args=(command, data))
  160. start_time = strftime("%b %d %H:%M:%S")
  161. thread.name = "irc:{0} ({1})".format(command.name, start_time)
  162. thread.daemon = True
  163. thread.start()
  164. return
  165. class TaskManager(_ResourceManager):
  166. """
  167. Manages (i.e., loads, reloads, schedules, and runs) wiki bot tasks.
  168. """
  169. def __init__(self, bot):
  170. super(TaskManager, self).__init__(bot, "tasks", Task)
  171. def _wrapper(self, task, **kwargs):
  172. """Wrapper for task classes: run the task and catch any errors."""
  173. try:
  174. task.run(**kwargs)
  175. except Exception:
  176. msg = "Task '{0}' raised an exception and had to stop:"
  177. self.logger.exception(msg.format(task.name))
  178. else:
  179. msg = "Task '{0}' finished successfully"
  180. self.logger.info(msg.format(task.name))
  181. def start(self, task_name, **kwargs):
  182. """Start a given task in a new daemon thread, and return the thread.
  183. kwargs are passed to :py:meth:`task.run() <earwigbot.tasks.Task.run>`.
  184. If the task is not found, ``None`` will be returned an an error is
  185. logged.
  186. """
  187. msg = "Starting task '{0}' in a new thread"
  188. self.logger.info(msg.format(task_name))
  189. try:
  190. task = self.get(task_name)
  191. except KeyError:
  192. e = "Couldn't find task '{0}'"
  193. self.logger.error(e.format(task_name))
  194. return
  195. task_thread = Thread(target=self._wrapper, args=(task,), kwargs=kwargs)
  196. start_time = strftime("%b %d %H:%M:%S")
  197. task_thread.name = "{0} ({1})".format(task_name, start_time)
  198. task_thread.daemon = True
  199. task_thread.start()
  200. return task_thread
  201. def schedule(self, now=None):
  202. """Start all tasks that are supposed to be run at a given time."""
  203. if not now:
  204. now = gmtime()
  205. # Get list of tasks to run this turn:
  206. tasks = self.bot.config.schedule(now.tm_min, now.tm_hour, now.tm_mday,
  207. now.tm_mon, now.tm_wday)
  208. for task in tasks:
  209. if isinstance(task, list): # They've specified kwargs,
  210. self.start(task[0], **task[1]) # so pass those to start
  211. else: # Otherwise, just pass task_name
  212. self.start(task)