Browse Source

Tons of refactoring, miscellaneous cleanup, and improvements.

* _ResourceManager: allow resources to be named anything as long as they
  inherit from the base resource class; gave resources proper names.

* Renamed BaseCommand to Command and BaseTask to Task; applied renames
  throughout earwigbot.commands and earwigbot.tasks.

* Data: refactored argument and command parsing to be completely internal.
  Added docstrings to attributes. Applied changes to Frontend.

* IRCConnection: improved such that we accurately detect disconnects with
  server pings; timeout support. Applied changes to Bot.

* Updated documentation and other minor fixes.
tags/v0.1^2
Ben Kurtovic 12 years ago
parent
commit
78ac1b8a80
37 changed files with 440 additions and 312 deletions
  1. +63
    -93
      docs/customizing.rst
  2. +20
    -15
      earwigbot/bot.py
  3. +4
    -4
      earwigbot/commands/__init__.py
  4. +4
    -2
      earwigbot/commands/afc_report.py
  5. +4
    -2
      earwigbot/commands/afc_status.py
  6. +4
    -2
      earwigbot/commands/calc.py
  7. +4
    -2
      earwigbot/commands/chanops.py
  8. +4
    -2
      earwigbot/commands/crypt.py
  9. +4
    -2
      earwigbot/commands/ctcp.py
  10. +4
    -2
      earwigbot/commands/editcount.py
  11. +4
    -2
      earwigbot/commands/git.py
  12. +4
    -2
      earwigbot/commands/help.py
  13. +4
    -2
      earwigbot/commands/link.py
  14. +4
    -2
      earwigbot/commands/praise.py
  15. +4
    -2
      earwigbot/commands/quit.py
  16. +4
    -2
      earwigbot/commands/registration.py
  17. +4
    -2
      earwigbot/commands/remind.py
  18. +4
    -2
      earwigbot/commands/replag.py
  19. +4
    -2
      earwigbot/commands/rights.py
  20. +5
    -3
      earwigbot/commands/test.py
  21. +4
    -10
      earwigbot/commands/threads.py
  22. +0
    -12
      earwigbot/exceptions.py
  23. +64
    -13
      earwigbot/irc/connection.py
  24. +140
    -40
      earwigbot/irc/data.py
  25. +5
    -21
      earwigbot/irc/frontend.py
  26. +36
    -36
      earwigbot/managers.py
  27. +3
    -3
      earwigbot/tasks/__init__.py
  28. +3
    -3
      earwigbot/tasks/afc_catdelink.py
  29. +3
    -3
      earwigbot/tasks/afc_copyvios.py
  30. +3
    -3
      earwigbot/tasks/afc_dailycats.py
  31. +3
    -3
      earwigbot/tasks/afc_history.py
  32. +5
    -3
      earwigbot/tasks/afc_statistics.py
  33. +3
    -3
      earwigbot/tasks/afc_undated.py
  34. +3
    -3
      earwigbot/tasks/blptag.py
  35. +3
    -3
      earwigbot/tasks/feed_dailycats.py
  36. +3
    -3
      earwigbot/tasks/wikiproject_tagger.py
  37. +3
    -3
      earwigbot/tasks/wrongmime.py

+ 63
- 93
docs/customizing.rst View File

@@ -78,44 +78,44 @@ and :py:attr:`config.irc["frontend"]["channels"]` will be
Custom IRC commands Custom IRC commands
------------------- -------------------


Custom commands are subclasses of :py:class:`earwigbot.commands.BaseCommand`
that override :py:class:`~earwigbot.commands.BaseCommand`'s
:py:meth:`~earwigbot.commands.BaseCommand.process` (and optionally
:py:meth:`~earwigbot.commands.BaseCommand.check`) methods.
:py:class:`~earwigbot.commands.BaseCommand`'s docstrings should explain what
each attribute and method is for and what they should be overridden with, but
these are the basics:
- Class attribute :py:attr:`~earwigbot.commands.BaseCommand.name` is the name
of the command. This must be specified.
- Class attribute :py:attr:`~earwigbot.commands.BaseCommand.hooks` is a list of
the "IRC events" that this command might respond to. It defaults to
``["msg"]``, but options include ``"msg_private"`` (for private messages
only), ``"msg_public"`` (for channel messages only), and ``"join"`` (for when
a user joins a channel). See the afc_status_ plugin for a command that
responds to other hook types.
- Method :py:meth:`~earwigbot.commands.BaseCommand.check` is passed a
:py:class:`~earwigbot.irc.data.Data` [1]_ object, and should return ``True``
if you want to respond to this message, or ``False`` otherwise. The default
behavior is to return ``True`` only if
:py:attr:`data.is_command` is ``True`` and :py:attr:`data.command` ==
:py:attr:`~earwigbot.commands.BaseCommand.name`, which is suitable for most
cases. A common, straightforward reason for overriding is if a command has
aliases (see chanops_ for an example). Note that by returning ``True``, you
prevent any other commands from responding to this message.
- Method :py:meth:`~earwigbot.commands.BaseCommand.process` is passed the same
Custom commands are subclasses of :py:class:`earwigbot.commands.Command` that
override :py:class:`~earwigbot.commands.Command`'s
:py:meth:`~earwigbot.commands.Command.process` (and optionally
:py:meth:`~earwigbot.commands.Command.check`) methods.
:py:class:`~earwigbot.commands.Command`'s docstrings should explain what each
attribute and method is for and what they should be overridden with, but these
are the basics:
- Class attribute :py:attr:`~earwigbot.commands.Command.name` is the name of
the command. This must be specified.
- Class attribute :py:attr:`~earwigbot.commands.Command.hooks` is a list of the
"IRC events" that this command might respond to. It defaults to ``["msg"]``,
but options include ``"msg_private"`` (for private messages only),
``"msg_public"`` (for channel messages only), and ``"join"`` (for when a user
joins a channel). See the afc_status_ plugin for a command that responds to
other hook types.
- Method :py:meth:`~earwigbot.commands.Command.check` is passed a
:py:class:`~earwigbot.irc.data.Data` object, and should return ``True`` if
you want to respond to this message, or ``False`` otherwise. The default
behavior is to return ``True`` only if :py:attr:`data.is_command` is ``True``
and :py:attr:`data.command` == :py:attr:`~earwigbot.commands.Command.name`,
which is suitable for most cases. A common, straightforward reason for
overriding is if a command has aliases (see chanops_ for an example). Note
that by returning ``True``, you prevent any other commands from responding to
this message.
- Method :py:meth:`~earwigbot.commands.Command.process` is passed the same
:py:class:`~earwigbot.irc.data.Data` object as :py:class:`~earwigbot.irc.data.Data` object as
:py:meth:`~earwigbot.commands.BaseCommand.check`, but only if
:py:meth:`~earwigbot.commands.BaseCommand.check` returned ``True``. This is
where the bulk of your command goes. To respond to IRC messages, there are a
number of methods of :py:class:`~earwigbot.commands.BaseCommand` at your
disposal. See the the test_ command for a simple example, or look in
:py:class:`~earwigbot.commands.BaseCommand`'s
:py:meth:`~earwigbot.commands.BaseCommand.__init__` method for the full list.
:py:meth:`~earwigbot.commands.Command.check`, but only if
:py:meth:`~earwigbot.commands.Command.check` returned ``True``. This is where
the bulk of your command goes. To respond to IRC messages, there are a number
of methods of :py:class:`~earwigbot.commands.Command` at your disposal. See
the test_ command for a simple example, or look in
:py:class:`~earwigbot.commands.Command`'s
:py:meth:`~earwigbot.commands.Command.__init__` method for the full list.


The most common ones are :py:meth:`say(chan_or_user, msg) The most common ones are :py:meth:`say(chan_or_user, msg)
<earwigbot.irc.connection.IRCConnection.say>`, :py:meth:`reply(data, msg) <earwigbot.irc.connection.IRCConnection.say>`, :py:meth:`reply(data, msg)
@@ -128,10 +128,10 @@ these are the basics:
<earwigbot.irc.connection.IRCConnection.join>`, and <earwigbot.irc.connection.IRCConnection.join>`, and
:py:meth:`part(chan) <earwigbot.irc.connection.IRCConnection.part>`. :py:meth:`part(chan) <earwigbot.irc.connection.IRCConnection.part>`.


It's important to name the command class :py:class:`Command` within the file,
or else the bot might not recognize it as a command. The name of the file
doesn't really matter and need not match the command's name, but this is
recommended for readability.
The command *class* doesn't need a specific name, but it should logically
follow the command's name. The filename doesn't matter, but it is recommended
to match the command name for readability. Multiple command classes are allowed
in one file.


The bot has a wide selection of built-in commands and plugins to act as sample The bot has a wide selection of built-in commands and plugins to act as sample
code and/or to give ideas. Start with test_, and then check out chanops_ and code and/or to give ideas. Start with test_, and then check out chanops_ and
@@ -140,48 +140,48 @@ afc_status_ for some more complicated scripts.
Custom bot tasks Custom bot tasks
---------------- ----------------


Custom tasks are subclasses of :py:class:`earwigbot.tasks.BaseTask` that
override :py:class:`~earwigbot.tasks.BaseTask`'s
:py:meth:`~earwigbot.tasks.BaseTask.run` (and optionally
:py:meth:`~earwigbot.tasks.BaseTask.setup`) methods.
Custom tasks are subclasses of :py:class:`earwigbot.tasks.Task` that
override :py:class:`~earwigbot.tasks.Task`'s
:py:meth:`~earwigbot.tasks.Task.run` (and optionally
:py:meth:`~earwigbot.tasks.Task.setup`) methods.


:py:class:`~earwigbot.tasks.BaseTask`'s docstrings should explain what each
:py:class:`~earwigbot.tasks.Task`'s docstrings should explain what each
attribute and method is for and what they should be overridden with, but these attribute and method is for and what they should be overridden with, but these
are the basics: are the basics:


- Class attribute :py:attr:`~earwigbot.tasks.BaseTask.name` is the name of the
- Class attribute :py:attr:`~earwigbot.tasks.Task.name` is the name of the
task. This must be specified. task. This must be specified.


- Class attribute :py:attr:`~earwigbot.tasks.BaseTask.number` can be used to
store an optional "task number", possibly for use in edit summaries (to be
generated with :py:meth:`~earwigbot.tasks.BaseTask.make_summary`). For
- Class attribute :py:attr:`~earwigbot.tasks.Task.number` can be used to store
an optional "task number", possibly for use in edit summaries (to be
generated with :py:meth:`~earwigbot.tasks.Task.make_summary`). For
example, EarwigBot's :py:attr:`config.wiki["summary"]` is example, EarwigBot's :py:attr:`config.wiki["summary"]` is
``"([[WP:BOT|Bot]]; [[User:EarwigBot#Task $1|Task $1]]): $2"``, which the ``"([[WP:BOT|Bot]]; [[User:EarwigBot#Task $1|Task $1]]): $2"``, which the
task class's :py:meth:`make_summary(comment) task class's :py:meth:`make_summary(comment)
<earwigbot.tasks.BaseTask.make_summary>` method will take and replace
<earwigbot.tasks.Task.make_summary>` method will take and replace
``$1`` with the task number and ``$2`` with the details of the edit. ``$1`` with the task number and ``$2`` with the details of the edit.
Additionally, :py:meth:`~earwigbot.tasks.BaseTask.shutoff_enabled` (which
checks whether the bot has been told to stop on-wiki by checking the content
of a particular page) can check a different page for each task using similar
Additionally, :py:meth:`~earwigbot.tasks.Task.shutoff_enabled` (which checks
whether the bot has been told to stop on-wiki by checking the content of a
particular page) can check a different page for each task using similar
variables. EarwigBot's :py:attr:`config.wiki["shutoff"]["page"]` is variables. EarwigBot's :py:attr:`config.wiki["shutoff"]["page"]` is
``"User:$1/Shutoff/Task $2"``; ``$1`` is substituted with the bot's username, ``"User:$1/Shutoff/Task $2"``; ``$1`` is substituted with the bot's username,
and ``$2`` is substituted with the task number, so, e.g., task #14 checks the and ``$2`` is substituted with the task number, so, e.g., task #14 checks the
page ``[[User:EarwigBot/Shutoff/Task 14]].`` If the page's content does *not* page ``[[User:EarwigBot/Shutoff/Task 14]].`` If the page's content does *not*
match :py:attr:`config.wiki["shutoff"]["disabled"]` (``"run"`` by default), match :py:attr:`config.wiki["shutoff"]["disabled"]` (``"run"`` by default),
then shutoff is considered to be *enabled* and then shutoff is considered to be *enabled* and
:py:meth:`~earwigbot.tasks.BaseTask.shutoff_enabled` will return ``True``,
:py:meth:`~earwigbot.tasks.Task.shutoff_enabled` will return ``True``,
indicating the task should not run. If you don't intend to use either of indicating the task should not run. If you don't intend to use either of
these methods, feel free to leave this attribute blank. these methods, feel free to leave this attribute blank.


- Method :py:meth:`~earwigbot.tasks.BaseTask.setup` is called *once* with no
- Method :py:meth:`~earwigbot.tasks.Task.setup` is called *once* with no
arguments immediately after the task is first loaded. Does nothing by arguments immediately after the task is first loaded. Does nothing by
default; treat it like an :py:meth:`__init__` if you want default; treat it like an :py:meth:`__init__` if you want
(:py:meth:`~earwigbot.tasks.BaseTask.__init__` does things by default and a
(:py:meth:`~earwigbot.tasks.Task.__init__` does things by default and a
dedicated setup method is often easier than overriding dedicated setup method is often easier than overriding
:py:meth:`~earwigbot.tasks.BaseTask.__init__` and using :py:obj:`super`).
:py:meth:`~earwigbot.tasks.Task.__init__` and using :py:obj:`super`).


- Method :py:meth:`~earwigbot.tasks.BaseTask.run` is called with any number of
- Method :py:meth:`~earwigbot.tasks.Task.run` is called with any number of
keyword arguments every time the task is executed (by keyword arguments every time the task is executed (by
:py:meth:`tasks.start(task_name, **kwargs) :py:meth:`tasks.start(task_name, **kwargs)
<earwigbot.managers.TaskManager.start>`, usually). This is where the bulk of <earwigbot.managers.TaskManager.start>`, usually). This is where the bulk of
@@ -194,45 +194,15 @@ which is a node in :file:`config.yml` like every other attribute of
or templates to append to user talk pages, so that these can be easily changed or templates to append to user talk pages, so that these can be easily changed
without modifying the task itself. without modifying the task itself.


It's important to name the task class :py:class:`Task` within the file, or else
the bot might not recognize it as a task. The name of the file doesn't really
matter and need not match the task's name, but this is recommended for
readability.
The task *class* doesn't need a specific name, but it should logically follow
the task's name. The filename doesn't matter, but it is recommended to match
the task name for readability. Multiple tasks classes are allowed in one file.


See the built-in wikiproject_tagger_ task for a relatively straightforward See the built-in wikiproject_tagger_ task for a relatively straightforward
task, or the afc_statistics_ plugin for a more complicated one. task, or the afc_statistics_ plugin for a more complicated one.


.. rubric:: Footnotes

.. [1] :py:class:`~earwigbot.irc.data.Data` objects are instances of
:py:class:`earwigbot.irc.Data <earwigbot.irc.data.Data>` that contain
information about a single message sent on IRC. Their useful attributes
are :py:attr:`~earwigbot.irc.data.Data.chan` (channel the message was
sent from, equal to :py:attr:`~earwigbot.irc.data.Data.nick` if it's a
private message), :py:attr:`~earwigbot.irc.data.Data.nick` (nickname of
the sender), :py:attr:`~earwigbot.irc.data.Data.ident` (ident_ of the
sender), :py:attr:`~earwigbot.irc.data.Data.host` (hostname of the
sender), :py:attr:`~earwigbot.irc.data.Data.msg` (text of the sent
message), :py:attr:`~earwigbot.irc.data.Data.is_command` (boolean
telling whether or not this message is a bot command, e.g., whether it
is prefixed by ``!``), :py:attr:`~earwigbot.irc.data.Data.command` (if
the message is a command, this is the name of the command used), and
:py:attr:`~earwigbot.irc.data.Data.args` (if the message is a command,
this is a list of the command arguments - for example, if issuing
"``!part ##earwig Goodbye guys``",
:py:attr:`~earwigbot.irc.data.Data.args` will equal
``["##earwig", "Goodbye", "guys"]``). Note that not all
:py:class:`~earwigbot.irc.data.Data` objects will have all of these
attributes: :py:class:`~earwigbot.irc.data.Data` objects generated by
private messages will, but ones generated by joins will only have
:py:attr:`~earwigbot.irc.data.Data.chan`,
:py:attr:`~earwigbot.irc.data.Data.nick`,
:py:attr:`~earwigbot.irc.data.Data.ident`,
and :py:attr:`~earwigbot.irc.data.Data.host`.

.. _afc_status: https://github.com/earwig/earwigbot-plugins/blob/develop/commands/afc_status.py .. _afc_status: https://github.com/earwig/earwigbot-plugins/blob/develop/commands/afc_status.py
.. _chanops: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/chanops.py .. _chanops: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/chanops.py
.. _test: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/test.py .. _test: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/test.py
.. _wikiproject_tagger: https://github.com/earwig/earwigbot/blob/develop/earwigbot/tasks/wikiproject_tagger.py .. _wikiproject_tagger: https://github.com/earwig/earwigbot/blob/develop/earwigbot/tasks/wikiproject_tagger.py
.. _afc_statistics: https://github.com/earwig/earwigbot-plugins/blob/develop/tasks/afc_statistics.py .. _afc_statistics: https://github.com/earwig/earwigbot-plugins/blob/develop/tasks/afc_statistics.py
.. _ident: http://en.wikipedia.org/wiki/Ident

+ 20
- 15
earwigbot/bot.py View File

@@ -75,17 +75,20 @@ class Bot(object):
self.commands.load() self.commands.load()
self.tasks.load() self.tasks.load()


def _dispatch_irc_component(self, name, klass):
"""Create a new IRC component, record it internally, and start it."""
component = klass(self)
setattr(self, name, component)
Thread(name="irc_" + name, target=component.loop).start()

def _start_irc_components(self): def _start_irc_components(self):
"""Start the IRC frontend/watcher in separate threads if enabled.""" """Start the IRC frontend/watcher in separate threads if enabled."""
if self.config.components.get("irc_frontend"): if self.config.components.get("irc_frontend"):
self.logger.info("Starting IRC frontend") self.logger.info("Starting IRC frontend")
self.frontend = Frontend(self)
Thread(name="irc_frontend", target=self.frontend.loop).start()

self._dispatch_irc_component("frontend", Frontend)
if self.config.components.get("irc_watcher"): if self.config.components.get("irc_watcher"):
self.logger.info("Starting IRC watcher") self.logger.info("Starting IRC watcher")
self.watcher = Watcher(self)
Thread(name="irc_watcher", target=self.watcher.loop).start()
self._dispatch_irc_component("watcher", Watcher)


def _start_wiki_scheduler(self): def _start_wiki_scheduler(self):
"""Start the wiki scheduler in a separate thread if enabled.""" """Start the wiki scheduler in a separate thread if enabled."""
@@ -104,6 +107,16 @@ class Bot(object):
thread.daemon = True # Stop if other threads stop thread.daemon = True # Stop if other threads stop
thread.start() thread.start()


def _keep_irc_component_alive(self, name, klass):
"""Ensure that IRC components stay connected, else restart them."""
component = getattr(self, name)
if component:
component.keep_alive()
if component.is_stopped():
log = "IRC {0} has stopped; restarting".format(name)
self.logger.warn(log)
self._dispatch_irc_component(name, klass)

def _stop_irc_components(self, msg): def _stop_irc_components(self, msg):
"""Request the IRC frontend and watcher to stop if enabled.""" """Request the IRC frontend and watcher to stop if enabled."""
if self.frontend: if self.frontend:
@@ -148,16 +161,8 @@ class Bot(object):
self._start_wiki_scheduler() self._start_wiki_scheduler()
while self._keep_looping: while self._keep_looping:
with self.component_lock: with self.component_lock:
if self.frontend and self.frontend.is_stopped():
name = "irc_frontend"
self.logger.warn("IRC frontend has stopped; restarting")
self.frontend = Frontend(self)
Thread(name=name, target=self.frontend.loop).start()
if self.watcher and self.watcher.is_stopped():
name = "irc_watcher"
self.logger.warn("IRC watcher has stopped; restarting")
self.watcher = Watcher(self)
Thread(name=name, target=self.watcher.loop).start()
self._keep_irc_component_alive("frontend", Frontend)
self._keep_irc_component_alive("watcher", Watcher)
sleep(2) sleep(2)


def restart(self, msg=None): def restart(self, msg=None):


+ 4
- 4
earwigbot/commands/__init__.py View File

@@ -20,9 +20,9 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


__all__ = ["BaseCommand"]
__all__ = ["Command"]


class BaseCommand(object):
class Command(object):
""" """
**EarwigBot: Base IRC Command** **EarwigBot: Base IRC Command**


@@ -30,8 +30,8 @@ class BaseCommand(object):
component. Additional commands can be installed as plugins in the bot's component. Additional commands can be installed as plugins in the bot's
working directory. working directory.


This class (import with ``from earwigbot.commands import BaseCommand``),
can be subclassed to create custom IRC commands.
This class (import with ``from earwigbot.commands import Command``), can be
subclassed to create custom IRC commands.


This docstring is reported to the user when they type ``"!help This docstring is reported to the user when they type ``"!help
<command>"``. <command>"``.


+ 4
- 2
earwigbot/commands/afc_report.py View File

@@ -21,9 +21,11 @@
# SOFTWARE. # SOFTWARE.


from earwigbot import wiki from earwigbot import wiki
from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["AFCReport"]

class AFCReport(Command):
"""Get information about an AFC submission by name.""" """Get information about an AFC submission by name."""
name = "report" name = "report"




+ 4
- 2
earwigbot/commands/afc_status.py View File

@@ -22,9 +22,11 @@


import re import re


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["AFCStatus"]

class AFCStatus(Command):
"""Get the number of pending AfC submissions, open redirect requests, and """Get the number of pending AfC submissions, open redirect requests, and
open file upload requests.""" open file upload requests."""
name = "status" name = "status"


+ 4
- 2
earwigbot/commands/calc.py View File

@@ -23,9 +23,11 @@
import re import re
import urllib import urllib


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Calc"]

class Calc(Command):
"""A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp """A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp
for details.""" for details."""
name = "calc" name = "calc"


+ 4
- 2
earwigbot/commands/chanops.py View File

@@ -20,9 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["ChanOps"]

class ChanOps(Command):
"""Voice, devoice, op, or deop users in the channel, or join or part from """Voice, devoice, op, or deop users in the channel, or join or part from
other channels.""" other channels."""
name = "chanops" name = "chanops"


+ 4
- 2
earwigbot/commands/crypt.py View File

@@ -24,9 +24,11 @@ import hashlib


from Crypto.Cipher import Blowfish from Crypto.Cipher import Blowfish


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Crypt"]

class Crypt(Command):
"""Provides hash functions with !hash (!hash list for supported algorithms) """Provides hash functions with !hash (!hash list for supported algorithms)
and blowfish encryption with !encrypt and !decrypt.""" and blowfish encryption with !encrypt and !decrypt."""
name = "crypt" name = "crypt"


+ 4
- 2
earwigbot/commands/ctcp.py View File

@@ -24,9 +24,11 @@ import platform
import time import time


from earwigbot import __version__ from earwigbot import __version__
from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["CTCP"]

class CTCP(Command):
"""Not an actual command; this module implements responses to the CTCP """Not an actual command; this module implements responses to the CTCP
requests PING, TIME, and VERSION.""" requests PING, TIME, and VERSION."""
name = "ctcp" name = "ctcp"


+ 4
- 2
earwigbot/commands/editcount.py View File

@@ -23,9 +23,11 @@
from urllib import quote_plus from urllib import quote_plus


from earwigbot import exceptions from earwigbot import exceptions
from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Editcount"]

class Editcount(Command):
"""Return a user's edit count.""" """Return a user's edit count."""
name = "editcount" name = "editcount"




+ 4
- 2
earwigbot/commands/git.py View File

@@ -24,9 +24,11 @@ import time


import git import git


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Git"]

class Git(Command):
"""Commands to interface with the bot's git repository; use '!git' for a """Commands to interface with the bot's git repository; use '!git' for a
sub-command list.""" sub-command list."""
name = "git" name = "git"


+ 4
- 2
earwigbot/commands/help.py View File

@@ -22,10 +22,12 @@


import re import re


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command
from earwigbot.irc import Data from earwigbot.irc import Data


class Command(BaseCommand):
__all__ = ["Help"]

class Help(Command):
"""Displays help information.""" """Displays help information."""
name = "help" name = "help"




+ 4
- 2
earwigbot/commands/link.py View File

@@ -23,9 +23,11 @@
import re import re
from urllib import quote from urllib import quote


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Link"]

class Link(Command):
"""Convert a Wikipedia page name into a URL.""" """Convert a Wikipedia page name into a URL."""
name = "link" name = "link"




+ 4
- 2
earwigbot/commands/praise.py View File

@@ -20,9 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Praise"]

class Praise(Command):
"""Praise people!""" """Praise people!"""
name = "praise" name = "praise"




+ 4
- 2
earwigbot/commands/quit.py View File

@@ -20,9 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Quit"]

class Quit(Command):
"""Quit, restart, or reload components from the bot. Only the owners can """Quit, restart, or reload components from the bot. Only the owners can
run this command.""" run this command."""
name = "quit" name = "quit"


+ 4
- 2
earwigbot/commands/registration.py View File

@@ -23,9 +23,11 @@
import time import time


from earwigbot import exceptions from earwigbot import exceptions
from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Registration"]

class Registration(Command):
"""Return when a user registered.""" """Return when a user registered."""
name = "registration" name = "registration"




+ 4
- 2
earwigbot/commands/remind.py View File

@@ -23,9 +23,11 @@
import threading import threading
import time import time


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Remind"]

class Remind(Command):
"""Set a message to be repeated to you in a certain amount of time.""" """Set a message to be repeated to you in a certain amount of time."""
name = "remind" name = "remind"




+ 4
- 2
earwigbot/commands/replag.py View File

@@ -24,9 +24,11 @@ from os.path import expanduser


import oursql import oursql


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Replag"]

class Replag(Command):
"""Return the replag for a specific database on the Toolserver.""" """Return the replag for a specific database on the Toolserver."""
name = "replag" name = "replag"




+ 4
- 2
earwigbot/commands/rights.py View File

@@ -21,9 +21,11 @@
# SOFTWARE. # SOFTWARE.


from earwigbot import exceptions from earwigbot import exceptions
from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Rights"]

class Rights(Command):
"""Retrieve a list of rights for a given username.""" """Retrieve a list of rights for a given username."""
name = "rights" name = "rights"




+ 5
- 3
earwigbot/commands/test.py View File

@@ -22,14 +22,16 @@


import random import random


from earwigbot.commands import BaseCommand
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Test"]

class Test(Command):
"""Test the bot!""" """Test the bot!"""
name = "test" name = "test"


def process(self, data): def process(self, data):
user = "\x02{0}\x0F".format(data.nick)
user = "\x02" + data.nick + "\x0F" # Wrap nick in bold
hey = random.randint(0, 1) hey = random.randint(0, 1)
if hey: if hey:
self.say(data.chan, "Hey {0}!".format(user)) self.say(data.chan, "Hey {0}!".format(user))


+ 4
- 10
earwigbot/commands/threads.py View File

@@ -23,10 +23,11 @@
import threading import threading
import re import re


from earwigbot.commands import BaseCommand
from earwigbot.exceptions import KwargParseError
from earwigbot.commands import Command


class Command(BaseCommand):
__all__ = ["Threads"]

class Threads(Command):
"""Manage wiki tasks from IRC, and check on thread status.""" """Manage wiki tasks from IRC, and check on thread status."""
name = "threads" name = "threads"


@@ -133,13 +134,6 @@ class Command(BaseCommand):
self.reply(data, "what task do you want me to start?") self.reply(data, "what task do you want me to start?")
return return


try:
data.parse_kwargs()
except KwargParseError, arg:
msg = "error parsing argument: \x0303{0}\x0301.".format(arg)
self.reply(data, msg)
return

if task_name not in self.bot.tasks: if task_name not in self.bot.tasks:
# This task does not exist or hasn't been loaded: # This task does not exist or hasn't been loaded:
msg = "task could not be found; either it doesn't exist, or it wasn't loaded correctly." msg = "task could not be found; either it doesn't exist, or it wasn't loaded correctly."


+ 0
- 12
earwigbot/exceptions.py View File

@@ -29,7 +29,6 @@ This module contains all exceptions used by EarwigBot::
+-- NoConfigError +-- NoConfigError
+-- IRCError +-- IRCError
| +-- BrokenSocketError | +-- BrokenSocketError
| +-- KwargParseError
+-- WikiToolsetError +-- WikiToolsetError
+-- SiteNotFoundError +-- SiteNotFoundError
+-- SiteAPIError +-- SiteAPIError
@@ -73,17 +72,6 @@ class BrokenSocketError(IRCError):
<earwigbot.irc.connection.IRCConnection._get>`. <earwigbot.irc.connection.IRCConnection._get>`.
""" """


class KwargParseError(IRCError):
"""Couldn't parse a certain keyword argument in an IRC message.

This is usually caused by it being given incorrectly: e.g., no value (abc),
just a value (=xyz), just an equal sign (=), instead of the correct form
(abc=xyz).

Raised by :py:meth:`Data.parse_kwargs
<earwigbot.irc.data.Data.parse_kwargs>`.
"""

class WikiToolsetError(EarwigBotError): class WikiToolsetError(EarwigBotError):
"""Base exception class for errors in the Wiki Toolset.""" """Base exception class for errors in the Wiki Toolset."""




+ 64
- 13
earwigbot/irc/connection.py View File

@@ -22,7 +22,7 @@


import socket import socket
from threading import Lock from threading import Lock
from time import sleep
from time import sleep, time


from earwigbot.exceptions import BrokenSocketError from earwigbot.exceptions import BrokenSocketError


@@ -32,16 +32,18 @@ class IRCConnection(object):
"""Interface with an IRC server.""" """Interface with an IRC server."""


def __init__(self, host, port, nick, ident, realname): def __init__(self, host, port, nick, ident, realname):
self.host = host
self.port = port
self.nick = nick
self.ident = ident
self.realname = realname
self._is_running = False
self._host = host
self._port = port
self._nick = nick
self._ident = ident
self._realname = realname


# A lock to prevent us from sending two messages at once:
self._is_running = False
self._send_lock = Lock() self._send_lock = Lock()


self._last_recv = time()
self._last_ping = 0

def _connect(self): def _connect(self):
"""Connect to our IRC server.""" """Connect to our IRC server."""
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -55,7 +57,7 @@ class IRCConnection(object):
self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname))


def _close(self): def _close(self):
"""Close our connection with the IRC server."""
"""Completely close our connection with the IRC server."""
try: try:
self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first
except socket.error: except socket.error:
@@ -73,16 +75,48 @@ class IRCConnection(object):
def _send(self, msg): def _send(self, msg):
"""Send data to the server.""" """Send data to the server."""
with self._send_lock: with self._send_lock:
self._sock.sendall(msg + "\r\n")
self.logger.debug(msg)
try:
self._sock.sendall(msg + "\r\n")
except socket.error:
self._is_running = False
else:
self.logger.debug(msg)


def _quit(self, msg=None): def _quit(self, msg=None):
"""Issue a quit message to the server."""
"""Issue a quit message to the server. Doesn't close the connection."""
if msg: if msg:
self._send("QUIT :{0}".format(msg)) self._send("QUIT :{0}".format(msg))
else: else:
self._send("QUIT") self._send("QUIT")


@property
def host(self):
"""The hostname of the IRC server, like ``"irc.freenode.net"``."""
return self._host

@property
def port(self):
"""The port of the IRC server, like ``6667``."""
return self._port

@property
def nick(self):
"""Our nickname on the server, like ``"EarwigBot"``."""
return self._nick

@property
def ident(self):
"""Our ident on the server, like ``"earwig"``.

See `http://en.wikipedia.org/wiki/Ident`_.
"""
return self._ident

@property
def realname(self):
"""Our realname (gecos field) on the server."""
return self._realname

def say(self, target, msg): def say(self, target, msg):
"""Send a private message to a target on the server.""" """Send a private message to a target on the server."""
msg = "PRIVMSG {0} :{1}".format(target, msg) msg = "PRIVMSG {0} :{1}".format(target, msg)
@@ -120,6 +154,11 @@ class IRCConnection(object):
msg = "MODE {0} {1} {2}".format(target, level, msg) msg = "MODE {0} {1} {2}".format(target, level, msg)
self._send(msg) self._send(msg)


def ping(self, target):
"""Ping another entity on the server."""
msg = "PING {0} {0}".format(target)
self._send(msg)

def pong(self, target): def pong(self, target):
"""Pong another entity on the server.""" """Pong another entity on the server."""
msg = "PONG {0}".format(target) msg = "PONG {0}".format(target)
@@ -136,14 +175,26 @@ class IRCConnection(object):
self._is_running = False self._is_running = False
break break


self._last_recv = time()
lines = read_buffer.split("\n") lines = read_buffer.split("\n")
read_buffer = lines.pop() read_buffer = lines.pop()
for line in lines: for line in lines:
self._process_message(line) self._process_message(line)
if self.is_stopped(): if self.is_stopped():
self._close()
break break


self._close()

def keep_alive(self):
"""Ensure that we stay connected, stopping if the connection breaks."""
now = time()
if now - self._last_recv > 60:
if self._last_ping < self._last_recv:
self.ping(self.host)
self._last_ping = now
elif now - self._last_ping > 60:
self.stop()

def stop(self, msg=None): def stop(self, msg=None):
"""Request the IRC connection to close at earliest convenience.""" """Request the IRC connection to close at earliest convenience."""
if self._is_running: if self._is_running:


+ 140
- 40
earwigbot/irc/data.py View File

@@ -22,72 +22,172 @@


import re import re


from earwigbot.exceptions import KwargParseError

__all__ = ["Data"] __all__ = ["Data"]


class Data(object): class Data(object):
"""Store data from an individual line received on IRC.""" """Store data from an individual line received on IRC."""


def __init__(self, bot, line):
self.line = line
self.my_nick = bot.config.irc["frontend"]["nick"].lower()
self.chan = self.nick = self.ident = self.host = self.msg = ""
def __init__(self, bot, my_nick, line, msgtype):
self._bot = bot
self._my_nick = my_nick
self._line = line


def parse_args(self):
"""Parse command arguments from the message.
self._is_private = self._is_command = False
self._msg = self._command = self._trigger = None
self._args = []
self._kwargs = {}

self._parse(msgtype)

def _parse(self, msgtype):
"""Parse a line from IRC into its components as instance attributes."""
sender = re.findall(":(.*?)!(.*?)@(.*?)\Z", line[0])[0]
self._nick, self._ident, self._host = sender
self._chan = self.line[2]


:py:attr:`self.msg <msg>` is converted into the string
:py:attr:`self.command <command>` and the argument list
:py:attr:`self.args <args>` if the message starts with a "trigger"
(``"!"``, ``"."``, or the bot's name); :py:attr:`self.is_command
<is_command>` will be set to ``True``, and :py:attr:`self.trigger
<trigger>` will store the trigger string. Otherwise,
:py:attr:`is_command` will be set to ``False``."""
args = self.msg.strip().split()
if msgtype == "PRIVMSG":
if self.chan == self.my_nick:
# This is a privmsg to us, so set 'chan' as the nick of the
# sender instead of the 'channel', which is ourselves:
self._chan = self._nick
self._is_private = True
self._msg = " ".join(line[3:])[1:]
self._parse_args()
self._parse_kwargs()


while "" in args:
args.remove("")
def _parse_args(self):
"""Parse command arguments from the message.


# Isolate command arguments:
self.args = args[1:]
self.is_command = False # Is this message a command?
self.trigger = None # What triggered this command? (!, ., or our nick)
self.msg is converted into the string self.command and the argument
list self.args if the message starts with a "trigger" ("!", ".", or the
bot's name); self.is_command will be set to True, and self.trigger will
store the trigger string. Otherwise, is_command will be set to False.
"""
self._args = self.msg.strip().split()[1:]


try: try:
self.command = args[0].lower()
self._command = args[0].lower()
except IndexError: except IndexError:
self.command = None
return return


if self.command.startswith("!") or self.command.startswith("."): if self.command.startswith("!") or self.command.startswith("."):
# e.g. "!command arg1 arg2" # e.g. "!command arg1 arg2"
self.is_command = True
self.trigger = self.command[0]
self.command = self.command[1:] # Strip the "!" or "."
self._is_command = True
self._trigger = self.command[0]
self._command = self.command[1:] # Strip the "!" or "."
elif self.command.startswith(self.my_nick): elif self.command.startswith(self.my_nick):
# e.g. "EarwigBot, command arg1 arg2" # e.g. "EarwigBot, command arg1 arg2"
self.is_command = True
self.trigger = self.my_nick
self._is_command = True
self._trigger = self.my_nick
try: try:
self.command = self.args.pop(0).lower()
self._command = self.args.pop(0).lower()
except IndexError: except IndexError:
self.command = ""
self._command = ""


def parse_kwargs(self):
"""Parse keyword arguments embedded in :py:attr:`self.args <args>`.
def _parse_kwargs(self):
"""Parse keyword arguments embedded in self.args.


Parse a command given as ``"!command key1=value1 key2=value2..."``
into a dict, :py:attr:`self.kwargs <kwargs>`, like
``{'key1': 'value2', 'key2': 'value2'...}``.
Parse a command given as "!command key1=value1 key2=value2..." into a
dict, self.kwargs, like {'key1': 'value2', 'key2': 'value2'...}.
""" """
self.kwargs = {}
for arg in self.args[2:]: for arg in self.args[2:]:
try: try:
key, value = re.findall("^(.*?)\=(.*?)$", arg)[0] key, value = re.findall("^(.*?)\=(.*?)$", arg)[0]
except IndexError: except IndexError:
raise KwargParseError(arg)
pass
if key and value: if key and value:
self.kwargs[key] = value self.kwargs[key] = value
else:
raise KwargParseError(arg)

@property
def my_nick(self):
"""Our nickname, *not* the nickname of the sender."""
return self._my_nick

@property
def line(self):
"""The full message received on IRC, including escape characters."""
return self._line

@property
def chan(self):
"""Channel the message was sent from.

This will be equal to :py:attr:`nick` if the message is a private
message.
"""
return self._chan

@property
def nick(self):
"""Nickname of the sender."""
return self._nick

@property
def ident(self):
"""`Ident <http://en.wikipedia.org/wiki/Ident>`_ of the sender."""
return self._ident

@property
def host(self):
"""Hostname of the sender."""
return self._host

@property
def msg(self):
"""Text of the sent message, if it is a message, else ``None``."""
return self._msg

@property
def is_private(self):
"""``True`` if this message was sent to us *only*, else ``False``."""
return self._is_private

@property
def is_command(self):
"""Boolean telling whether or not this message is a bot command.

A message is considered a command if and only if it begins with the
character ``"!"``, ``"."``, or the bot's name followed by optional
punctuation and a space (so ``EarwigBot: do something``, ``EarwigBot,
do something``, and ``EarwigBot do something`` are all valid).
"""
return self._is_command

@property
def command(self):
"""If the message is a command, this is the name of the command used.

See :py:attr:`is_command <self.is_command>` for when a message is
considered a command. If it's not a command, this will be set to
``None``.
"""
return self._command

@property
def trigger(self):
"""If this message is a command, this is what triggered it.

It can be either "!" (``"!help"``), "." (``".help"``), or the bot's
name (``"EarwigBot: help"``). Otherwise, it will be ``None``."""
return self._trigger

@property
def args(self):
"""List of all arguments given to this command.

For example, the message ``"!command arg1 arg2 arg3=val3"`` will
produce the args ``["arg1", "arg2", "arg3=val3"]``. This is empty if
the message was not a command or if it doesn't have arguments.
"""
return self._args

@property
def kwargs(self):
"""Dictionary of keyword arguments given to this command.

For example, the message ``"!command arg1=val1 arg2=val2"`` will
produce the kwargs ``{"arg1": "val1", "arg2": "val2"}``. This is empty
if the message was not a command or if it doesn't have keyword
arguments.
"""
return self._kwargs

+ 5
- 21
earwigbot/irc/frontend.py View File

@@ -20,8 +20,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


import re

from earwigbot.irc import IRCConnection, Data from earwigbot.irc import IRCConnection, Data


__all__ = ["Frontend"] __all__ = ["Frontend"]
@@ -32,13 +30,12 @@ class Frontend(IRCConnection):


The IRC frontend runs on a normal IRC server and expects users to interact The IRC frontend runs on a normal IRC server and expects users to interact
with it and give it commands. Commands are stored as "command classes", with it and give it commands. Commands are stored as "command classes",
subclasses of :py:class:`~earwigbot.commands.BaseCommand`. All command
classes are automatically imported by :py:meth:`commands.load()
subclasses of :py:class:`~earwigbot.commands.Command`. All command classes
are automatically imported by :py:meth:`commands.load()
<earwigbot.managers._ResourceManager.load>` if they are in <earwigbot.managers._ResourceManager.load>` if they are in
:py:mod:`earwigbot.commands` or the bot's custom command directory :py:mod:`earwigbot.commands` or the bot's custom command directory
(explained in the :doc:`documentation </customizing>`). (explained in the :doc:`documentation </customizing>`).
""" """
sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z")


def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@@ -53,30 +50,17 @@ class Frontend(IRCConnection):
def _process_message(self, line): def _process_message(self, line):
"""Process a single message from IRC.""" """Process a single message from IRC."""
line = line.strip().split() line = line.strip().split()
data = Data(self.bot, line)


if line[1] == "JOIN": if line[1] == "JOIN":
data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0]
data.chan = line[2]
data.parse_args()
data = Data(self.bot, self.nick, line, msgtype="JOIN")
self.bot.commands.call("join", data) self.bot.commands.call("join", data)


elif line[1] == "PRIVMSG": elif line[1] == "PRIVMSG":
data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0]
data.msg = " ".join(line[3:])[1:]
data.chan = line[2]
data.parse_args()

if data.chan == self.bot.config.irc["frontend"]["nick"]:
# This is a privmsg to us, so set 'chan' as the nick of the
# sender, then check for private-only command hooks:
data.chan = data.nick
data = Data(self.bot, self.nick, line, msgtype="PRIVMSG")
if data.is_private:
self.bot.commands.call("msg_private", data) self.bot.commands.call("msg_private", data)
else: else:
# Check for public-only command hooks:
self.bot.commands.call("msg_public", data) self.bot.commands.call("msg_public", data)

# Check for command hooks that apply to all messages:
self.bot.commands.call("msg", data) self.bot.commands.call("msg", data)


elif line[0] == "PING": # If we are pinged, pong back elif line[0] == "PING": # If we are pinged, pong back


+ 36
- 36
earwigbot/managers.py View File

@@ -27,8 +27,8 @@ from re import sub
from threading import Lock, Thread from threading import Lock, Thread
from time import gmtime, strftime from time import gmtime, strftime


from earwigbot.commands import BaseCommand
from earwigbot.tasks import BaseTask
from earwigbot.commands import Command
from earwigbot.tasks import Task


__all__ = ["CommandManager", "TaskManager"] __all__ = ["CommandManager", "TaskManager"]


@@ -52,32 +52,40 @@ class _ResourceManager(object):
``with`` statement) so an attempt at reloading resources in another thread ``with`` statement) so an attempt at reloading resources in another thread
won't disrupt your iteration. won't disrupt your iteration.
""" """
def __init__(self, bot, name, attribute, base):
def __init__(self, bot, name, base):
self.bot = bot self.bot = bot
self.logger = bot.logger.getChild(name) self.logger = bot.logger.getChild(name)


self._resources = {} self._resources = {}
self._resource_name = name # e.g. "commands" or "tasks" self._resource_name = name # e.g. "commands" or "tasks"
self._resource_attribute = attribute # e.g. "Command" or "Task"
self._resource_base = base # e.g. BaseCommand or BaseTask
self._resource_base = base # e.g. Command or Task
self._resource_access_lock = Lock() self._resource_access_lock = Lock()


@property
def lock(self):
"""The resource access/modify lock."""
return self._resource_access_lock

def __iter__(self): def __iter__(self):
for name in self._resources: for name in self._resources:
yield name yield name


def _load_resource(self, name, path):
def _load_resource(self, name, path, klass):
"""Instantiate a resource class and add it to the dictionary."""
res_type = self._resource_name[:-1] # e.g. "command" or "task"
try:
resource = klass(self.bot) # Create instance of resource
except Exception:
e = "Error instantiating {0} class in {1} (from {2})"
self.logger.exception(e.format(res_type, name, path))
else:
self._resources[resource.name] = resource
self.logger.debug("Loaded {0} {1}".format(res_type, resource.name))

def _load_module(self, name, path):
"""Load a specific resource from a module, identified by name and path. """Load a specific resource from a module, identified by name and path.


We'll first try to import it using imp magic, and if that works, make We'll first try to import it using imp magic, and if that works, make
an instance of the 'Command' class inside (assuming it is an instance
of BaseCommand), add it to self._commands, and log the addition. Any
problems along the way will either be ignored or logged.
instances of any classes inside that are subclasses of the base
(:py:attr:`self._resource_base <_resource_base>`), add them to the
resources dictionary with :py:meth:`self._load_resource()
<_load_resource>`, and finally log the addition. Any problems along
the way will either be ignored or logged.
""" """
f, path, desc = imp.find_module(name, [path]) f, path, desc = imp.find_module(name, [path])
try: try:
@@ -89,24 +97,13 @@ class _ResourceManager(object):
finally: finally:
f.close() f.close()


attr = self._resource_attribute
if not hasattr(module, attr):
return # No resources in this module
resource_class = getattr(module, attr)
try:
resource = resource_class(self.bot) # Create instance of resource
except Exception:
e = "Error instantiating {0} class in {1} (from {2})"
self.logger.exception(e.format(attr, name, path))
return
if not isinstance(resource, self._resource_base):
return

self._resources[resource.name] = resource
self.logger.debug("Loaded {0} {1}".format(attr.lower(), resource.name))
for obj in vars(module).values():
if type(obj) is type and isinstance(obj, self._resource_base):
self._load_resource(name, path, obj)


def _load_directory(self, dir): def _load_directory(self, dir):
"""Load all valid resources in a given directory.""" """Load all valid resources in a given directory."""
self.logger.debug("Loading directory {0}".format(dir))
processed = [] processed = []
for name in listdir(dir): for name in listdir(dir):
if not name.endswith(".py") and not name.endswith(".pyc"): if not name.endswith(".py") and not name.endswith(".pyc"):
@@ -115,9 +112,14 @@ class _ResourceManager(object):
continue continue
modname = sub("\.pyc?$", "", name) # Remove extension modname = sub("\.pyc?$", "", name) # Remove extension
if modname not in processed: if modname not in processed:
self._load_resource(modname, dir)
self._load_module(modname, dir)
processed.append(modname) processed.append(modname)


@property
def lock(self):
"""The resource access/modify lock."""
return self._resource_access_lock

def load(self): def load(self):
"""Load (or reload) all valid resources into :py:attr:`_resources`.""" """Load (or reload) all valid resources into :py:attr:`_resources`."""
name = self._resource_name # e.g. "commands" or "tasks" name = self._resource_name # e.g. "commands" or "tasks"
@@ -146,8 +148,7 @@ class CommandManager(_ResourceManager):
Manages (i.e., loads, reloads, and calls) IRC commands. Manages (i.e., loads, reloads, and calls) IRC commands.
""" """
def __init__(self, bot): def __init__(self, bot):
base = super(CommandManager, self)
base.__init__(bot, "commands", "Command", BaseCommand)
super(CommandManager, self).__init__(bot, "commands", Command)


def _wrap_check(self, command, data): def _wrap_check(self, command, data):
"""Check whether a command should be called, catching errors.""" """Check whether a command should be called, catching errors."""
@@ -181,7 +182,7 @@ class TaskManager(_ResourceManager):
Manages (i.e., loads, reloads, schedules, and runs) wiki bot tasks. Manages (i.e., loads, reloads, schedules, and runs) wiki bot tasks.
""" """
def __init__(self, bot): def __init__(self, bot):
super(TaskManager, self).__init__(bot, "tasks", "Task", BaseTask)
super(TaskManager, self).__init__(bot, "tasks", Task)


def _wrapper(self, task, **kwargs): def _wrapper(self, task, **kwargs):
"""Wrapper for task classes: run the task and catch any errors.""" """Wrapper for task classes: run the task and catch any errors."""
@@ -197,9 +198,8 @@ class TaskManager(_ResourceManager):
def start(self, task_name, **kwargs): def start(self, task_name, **kwargs):
"""Start a given task in a new daemon thread, and return the thread. """Start a given task in a new daemon thread, and return the thread.


kwargs are passed to :py:meth:`task.run() <earwigbot.tasks.BaseTask>`.
If the task is not found, ``None`` will be returned an an error is
logged.
kwargs are passed to :py:meth:`task.run() <earwigbot.tasks.Task>`. If
the task is not found, ``None`` will be returned an an error is logged.
""" """
msg = "Starting task '{0}' in a new thread" msg = "Starting task '{0}' in a new thread"
self.logger.info(msg.format(task_name)) self.logger.info(msg.format(task_name))


+ 3
- 3
earwigbot/tasks/__init__.py View File

@@ -23,16 +23,16 @@
from earwigbot import exceptions from earwigbot import exceptions
from earwigbot import wiki from earwigbot import wiki


__all__ = ["BaseTask"]
__all__ = ["Task"]


class BaseTask(object):
class Task(object):
""" """
**EarwigBot: Base Bot Task** **EarwigBot: Base Bot Task**


This package provides built-in wiki bot "tasks" EarwigBot runs. Additional This package provides built-in wiki bot "tasks" EarwigBot runs. Additional
tasks can be installed as plugins in the bot's working directory. tasks can be installed as plugins in the bot's working directory.


This class (import with ``from earwigbot.tasks import BaseTask``) can be
This class (import with ``from earwigbot.tasks import Task``) can be
subclassed to create custom bot tasks. subclassed to create custom bot tasks.


To run a task, use :py:meth:`bot.tasks.start(name, **kwargs) To run a task, use :py:meth:`bot.tasks.start(name, **kwargs)


+ 3
- 3
earwigbot/tasks/afc_catdelink.py View File

@@ -20,11 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["AFCCatDelink"]


class Task(BaseTask):
class AFCCatDelink(Task):
"""A task to delink mainspace categories in declined [[WP:AFC]] """A task to delink mainspace categories in declined [[WP:AFC]]
submissions.""" submissions."""
name = "afc_catdelink" name = "afc_catdelink"


+ 3
- 3
earwigbot/tasks/afc_copyvios.py View File

@@ -26,11 +26,11 @@ from threading import Lock


import oursql import oursql


from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["AFCCopyvios"]


class Task(BaseTask):
class AFCCopyvios(Task):
"""A task to check newly-edited [[WP:AFC]] submissions for copyright """A task to check newly-edited [[WP:AFC]] submissions for copyright
violations.""" violations."""
name = "afc_copyvios" name = "afc_copyvios"


+ 3
- 3
earwigbot/tasks/afc_dailycats.py View File

@@ -20,11 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["AFCDailyCats"]


class Task(BaseTask):
class AFCDailyCats(Task):
""" A task to create daily categories for [[WP:AFC]].""" """ A task to create daily categories for [[WP:AFC]]."""
name = "afc_dailycats" name = "afc_dailycats"
number = 3 number = 3


+ 3
- 3
earwigbot/tasks/afc_history.py View File

@@ -32,11 +32,11 @@ from numpy import arange
import oursql import oursql


from earwigbot import wiki from earwigbot import wiki
from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["AFCHistory"]


class Task(BaseTask):
class AFCHistory(Task):
"""A task to generate charts about AfC submissions over time. """A task to generate charts about AfC submissions over time.


The main function of the task is to work through the "AfC submissions by The main function of the task is to work through the "AfC submissions by


+ 5
- 3
earwigbot/tasks/afc_statistics.py View File

@@ -30,11 +30,11 @@ import oursql


from earwigbot import exceptions from earwigbot import exceptions
from earwigbot import wiki from earwigbot import wiki
from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["AFCStatistics"]


class Task(BaseTask):
class AFCStatistics(Task):
"""A task to generate statistics for WikiProject Articles for Creation. """A task to generate statistics for WikiProject Articles for Creation.


Statistics are stored in a MySQL database ("u_earwig_afc_statistics") Statistics are stored in a MySQL database ("u_earwig_afc_statistics")
@@ -87,7 +87,9 @@ class Task(BaseTask):
action = kwargs.get("action") action = kwargs.get("action")
if not self.db_access_lock.acquire(False): # Non-blocking if not self.db_access_lock.acquire(False): # Non-blocking
if action == "sync": if action == "sync":
self.logger.info("A sync is already ongoing; aborting")
return return
self.logger.info("Waiting for database access lock")
self.db_access_lock.acquire() self.db_access_lock.acquire()


try: try:


+ 3
- 3
earwigbot/tasks/afc_undated.py View File

@@ -20,11 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["AFCUndated"]


class Task(BaseTask):
class AFCUndated(Task):
"""A task to clear [[Category:Undated AfC submissions]].""" """A task to clear [[Category:Undated AfC submissions]]."""
name = "afc_undated" name = "afc_undated"




+ 3
- 3
earwigbot/tasks/blptag.py View File

@@ -20,11 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["BLPTag"]


class Task(BaseTask):
class BLPTag(Task):
"""A task to add |blp=yes to ``{{WPB}}`` or ``{{WPBS}}`` when it is used """A task to add |blp=yes to ``{{WPB}}`` or ``{{WPBS}}`` when it is used
along with ``{{WP Biography}}``.""" along with ``{{WP Biography}}``."""
name = "blptag" name = "blptag"


+ 3
- 3
earwigbot/tasks/feed_dailycats.py View File

@@ -20,11 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["FeedDailyCats"]


class Task(BaseTask):
class FeedDailyCats(Task):
"""A task to create daily categories for [[WP:FEED]].""" """A task to create daily categories for [[WP:FEED]]."""
name = "feed_dailycats" name = "feed_dailycats"




+ 3
- 3
earwigbot/tasks/wikiproject_tagger.py View File

@@ -20,11 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["WikiProjectTagger"]


class Task(BaseTask):
class WikiProjectTagger(Task):
"""A task to tag talk pages with WikiProject Banners.""" """A task to tag talk pages with WikiProject Banners."""
name = "wikiproject_tagger" name = "wikiproject_tagger"




+ 3
- 3
earwigbot/tasks/wrongmime.py View File

@@ -20,11 +20,11 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from earwigbot.tasks import BaseTask
from earwigbot.tasks import Task


__all__ = ["Task"]
__all__ = ["WrongMIME"]


class Task(BaseTask):
class WrongMIME(Task):
"""A task to tag files whose extensions do not agree with their MIME """A task to tag files whose extensions do not agree with their MIME
type.""" type."""
name = "wrongmime" name = "wrongmime"


Loading…
Cancel
Save