From c2f039c60becf42ff60ca11037c2e3361efa2d69 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 23 Jul 2013 21:02:06 +0200 Subject: [PATCH] Aka: First commit. --- plugins/Aka/README.txt | 1 + plugins/Aka/__init__.py | 69 ++++++++ plugins/Aka/config.py | 56 +++++++ plugins/Aka/local/__init__.py | 1 + plugins/Aka/plugin.py | 305 ++++++++++++++++++++++++++++++++++ plugins/Aka/test.py | 140 ++++++++++++++++ 6 files changed, 572 insertions(+) create mode 100644 plugins/Aka/README.txt create mode 100644 plugins/Aka/__init__.py create mode 100644 plugins/Aka/config.py create mode 100644 plugins/Aka/local/__init__.py create mode 100644 plugins/Aka/plugin.py create mode 100644 plugins/Aka/test.py diff --git a/plugins/Aka/README.txt b/plugins/Aka/README.txt new file mode 100644 index 000000000..d60b47a97 --- /dev/null +++ b/plugins/Aka/README.txt @@ -0,0 +1 @@ +Insert a description of your plugin here, with any notes, etc. about using it. diff --git a/plugins/Aka/__init__.py b/plugins/Aka/__init__.py new file mode 100644 index 000000000..f6f870dda --- /dev/null +++ b/plugins/Aka/__init__.py @@ -0,0 +1,69 @@ +### +# Copyright (c) 2013, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +""" +Add a description of the plugin (to be presented to the user inside the wizard) +here. This should describe *what* the plugin does. +""" + +import supybot +import supybot.world as world + +# Use this for the version of this plugin. You may wish to put a CVS keyword +# in here if you're keeping the plugin in CVS or some similar system. +__version__ = "" + +# XXX Replace this with an appropriate author or supybot.Author instance. +__author__ = supybot.authors.unknown + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = '' # 'http://supybot.com/Members/yourname/Aka/download' + +from . import config +from . import plugin +from imp import reload +# In case we're being reloaded. +reload(config) +reload(plugin) +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + from . import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/Aka/config.py b/plugins/Aka/config.py new file mode 100644 index 000000000..4642d7793 --- /dev/null +++ b/plugins/Aka/config.py @@ -0,0 +1,56 @@ +### +# Copyright (c) 2013, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +import supybot.conf as conf +import supybot.registry as registry +try: + from supybot.i18n import PluginInternationalization + _ = PluginInternationalization('Aka') +except: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x:x + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Aka', True) + + +Aka = conf.registerPlugin('Aka') +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(Aka, 'someConfigVariableName', +# registry.Boolean(False, _("""Help for someConfigVariableName."""))) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/Aka/local/__init__.py b/plugins/Aka/local/__init__.py new file mode 100644 index 000000000..e86e97b86 --- /dev/null +++ b/plugins/Aka/local/__init__.py @@ -0,0 +1 @@ +# Stub so local is a module, used for third-party modules diff --git a/plugins/Aka/plugin.py b/plugins/Aka/plugin.py new file mode 100644 index 000000000..b84694ff8 --- /dev/null +++ b/plugins/Aka/plugin.py @@ -0,0 +1,305 @@ +### +# Copyright (c) 2013, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +import re +import os +import sys + +import supybot.utils as utils +from supybot.commands import * +import supybot.plugins as plugins +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks +from supybot.i18n import PluginInternationalization +_ = PluginInternationalization('Aka') + +try: + import sqlalchemy + import sqlalchemy.ext + import sqlalchemy.ext.declarative +except ImportError: + sqlalchemy = None + +if sqlalchemy: + + Base = sqlalchemy.ext.declarative.declarative_base() + class Alias(Base): + __tablename__ = 'aliases' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + name = sqlalchemy.Column(sqlalchemy.String) + alias = sqlalchemy.Column(sqlalchemy.String) + + def __init__(self, name, alias): + self.name = name + self.alias = alias + def __repr__(self): + return "" % (self.name, self.alias) + + # TODO: Add table for locks + # TODO: Add table for usage statistics + + class SqlAlchemyAkaDB(object): + def __init__(self, filename): + self.engines = ircutils.IrcDict() + self.filename = filename + self.sqlalchemy = sqlalchemy + + def close(self): + self.dbs.clear() + + def get_db(self, channel): + if channel in self.engines: + engine = self.engines[channel] + else: + filename = plugins.makeChannelFilename(self.filename, channel) + exists = os.path.exists(filename) + engine = sqlalchemy.create_engine('sqlite:///' + filename) + if not exists: + Base.metadata.create_all(engine) + self.engines[channel] = engine + assert engine.execute("select 1").scalar() == 1 + Session = sqlalchemy.orm.sessionmaker() + Session.configure(bind=engine) + return Session() + + + def has_aka(self, channel, name): + count = self.get_db(channel).query(Alias) \ + .filter(Alias.name == name) \ + .count() + return bool(count) + def get_aka_list(self, channel): + list_ = list(self.get_db(channel).query(Alias.name)) + return list_ + + def get_alias(self, channel, name): + return self.get_db(channel).query(Alias.alias) \ + .filter(Alias.name == name).one()[0] + + def add_aka(self, channel, name, alias): + if sys.version_info[0] < 3: + if isinstance(name, str): + name = name.decode('utf8') + if isinstance(alias, str): + alias = alias.decode('utf8') + db = self.get_db(channel) + db.add(Alias(name, alias)) + db.commit() + + def remove_aka(self, channel, name): + db = self.get_db(channel) + db.query(Alias).filter(Alias.name == name).delete() + db.commit() + +def getArgs(args, required=1, optional=0, wildcard=0): + if len(args) < required: + raise callbacks.ArgumentError + if len(args) < required + optional: + ret = list(args) + ([''] * (required + optional - len(args))) + elif len(args) >= required + optional: + if not wildcard: + ret = list(args[:required + optional - 1]) + ret.append(' '.join(args[required + optional - 1:])) + else: + ret = list(args) + return ret + +class AliasError(Exception): + pass + +class RecursiveAlias(AliasError): + pass + +dollarRe = re.compile(r'\$(\d+)') +def findBiggestDollar(alias): + dollars = dollarRe.findall(alias) + dollars = map(int, dollars) + dollars.sort() + if dollars: + return dollars[-1] + else: + return 0 + +atRe = re.compile(r'@(\d+)') +def findBiggestAt(alias): + ats = atRe.findall(alias) + ats = map(int, ats) + ats.sort() + if ats: + return ats[-1] + else: + return 0 + +AkaDB = plugins.DB('Aka', {'sqlalchemy': SqlAlchemyAkaDB}) + +class Aka(callbacks.Plugin): + """Add the help for "@plugin help Aka" here + This should describe *how* to use this plugin.""" + + def __init__(self, irc): + self.__parent = super(Aka, self) + self.__parent.__init__(irc) + self._db = AkaDB() + + def isCommandMethod(self, name): + channel = dynamic.channel + return self._db.has_aka(channel, name) or \ + self._db.has_aka('global', name) or \ + self.__parent.isCommandMethod(name) + + def listCommands(self): + channel = dynamic.channel + return set(self._db.get_aka_list(channel) + + self._db.get_aka_list() + + self.__parent.listCommands()) + + def getCommandMethod(self, command=None, name=None): + if command: + assert name is None + try: + return self.__parent.getCommandMethod(command) + except AttributeError: + pass + name = name or callbacks.formatCommand(command) + channel = dynamic.channel + original = self._db.get_alias(channel, name) + if not original: + original = self._db.get_alias('global', name) + biggestDollar = findBiggestDollar(original) + biggestAt = findBiggestAt(original) + wildcard = '$*' in original + def f(irc, msg, args): + tokens = callbacks.tokenize(original) + if biggestDollar or biggestAt: + args = getArgs(args, required=biggestDollar, optional=biggestAt, + wildcard=wildcard) + def regexpReplace(m): + idx = int(m.group(1)) + return args[idx-1] + def replace(tokens, replacer): + for (i, token) in enumerate(tokens): + if isinstance(token, list): + replace(token, replacer) + else: + tokens[i] = replacer(token) + replace(tokens, lambda s: dollarRe.sub(regexpReplace, s)) + if biggestAt: + assert not wildcard + args = args[biggestDollar:] + replace(tokens, lambda s: atRe.sub(regexpReplace, s)) + if wildcard: + assert not biggestAt + # Gotta remove the things that have already been subbed in. + i = biggestDollar + while i: + args.pop(0) + i -= 1 + def everythingReplace(tokens): + for (i, token) in enumerate(tokens): + if isinstance(token, list): + if everythingReplace(token): + return + if token == '$*': + tokens[i:i+1] = args + return True + elif '$*' in token: + tokens[i] = token.replace('$*', ' '.join(args)) + return True + return False + everythingReplace(tokens) + self.Proxy(irc, msg, tokens) + if biggestDollar and (wildcard or biggestAt): + flexargs = _(' at least') + else: + flexargs = '' + doc = format(_('\n\nAlias for %q.'), + flexargs, (biggestDollar, _('argument')), original) + f = utils.python.changeFunctionName(f, name, doc) + return f + + def _add_aka(self, channel, name, alias): + if self.isCommandMethod(name): + raise AliasError(_('You can\'t overwrite commands in ' + 'this plugin.')) + biggestDollar = findBiggestDollar(alias) + biggestAt = findBiggestAt(alias) + wildcard = '$*' in alias + if biggestAt and wildcard: + raise AliasError('Can\'t mix $* and optional args (@1, etc.)') + if alias.count('$*') > 1: + raise AliasError('There can be only one $* in an alias.') + self._db.add_aka(channel, name, alias) + + def _remove_aka(self, channel, name): + self._db.remove_aka(channel, name) + + def add(self, irc, msg, args, channel, name, alias): + """[<#channel|global>] + + Defines an alias that executes . The + should be in the standard "command argument [nestedcommand argument]" + arguments to the alias; they'll be filled with the first, second, etc. + arguments. $1, $2, etc. can be used for required arguments. @1, @2, + etc. can be used for optional arguments. $* simply means "all + remaining arguments," and cannot be combined with optional arguments. + """ + if ' ' not in alias: + # If it's a single word, they probably want $*. + alias += ' $*' + try: + self._add_aka(channel, name, alias) + self.log.info('Adding Aka %q for %q (from %s)', + name, alias, msg.prefix) + irc.replySuccess() + except AliasError as e: + irc.error(str(e)) + add = wrap(add, [first(('literal', 'global'), 'channel'), + 'commandName', 'text']) + + def remove(self, irc, msg, args, channel, name): + """[<#channel|global>] + + Removes the given alias, if unlocked. + """ + try: + self._remove_aka(channel, name) + self.log.info('Removing Aka %q (from %s)', name, msg.prefix) + irc.replySuccess() + except AliasError as e: + irc.error(str(e)) + remove = wrap(remove, [first(('literal', 'global'), 'channel'), + 'commandName']) + + +Class = Aka + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Aka/test.py b/plugins/Aka/test.py new file mode 100644 index 000000000..77caa8a26 --- /dev/null +++ b/plugins/Aka/test.py @@ -0,0 +1,140 @@ +# -*- coding: utf8 -*- +### +# Copyright (c) 2002-2004, Jeremiah Fincher +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +from supybot.test import * + +import supybot.conf as conf +import supybot.plugin as plugin +import supybot.registry as registry + +import plugin as Aka + +class FunctionsTest(SupyTestCase): + def testFindBiggestDollar(self): + self.assertEqual(Aka.findBiggestDollar(''), 0) + self.assertEqual(Aka.findBiggestDollar('foo'), 0) + self.assertEqual(Aka.findBiggestDollar('$0'), 0) + self.assertEqual(Aka.findBiggestDollar('$1'), 1) + self.assertEqual(Aka.findBiggestDollar('$2'), 2) + self.assertEqual(Aka.findBiggestDollar('$2 $10'), 10) + self.assertEqual(Aka.findBiggestDollar('$3'), 3) + self.assertEqual(Aka.findBiggestDollar('$3 $2 $1'), 3) + self.assertEqual(Aka.findBiggestDollar('foo bar $1'), 1) + self.assertEqual(Aka.findBiggestDollar('foo $2 $1'), 2) + self.assertEqual(Aka.findBiggestDollar('foo $0 $1'), 1) + self.assertEqual(Aka.findBiggestDollar('foo $1 $3'), 3) + self.assertEqual(Aka.findBiggestDollar('$10 bar $1'), 10) + + +class AkaTestCase(ChannelPluginTestCase): + plugins = ('Aka', 'Filter', 'Utilities', 'Format', 'Reply') + + def testDoesNotOverwriteCommands(self): + # We don't have dispatcher commands anymore + #self.assertError('aka add aka "echo foo bar baz"') + self.assertError('aka add add "echo foo bar baz"') + self.assertError('aka add remove "echo foo bar baz"') + self.assertError('aka add lock "echo foo bar baz"') + self.assertError('aka add unlock "echo foo bar baz"') + + def testAkaHelp(self): + self.assertNotError('aka add slashdot foo') + self.assertRegexp('help slashdot', "Alias for .*foo") + self.assertNotError('aka add nonascii echo éé') + self.assertRegexp('help nonascii', "Alias for .*echo éé") + + def testRemove(self): + self.assertNotError('aka add foo echo bar') + self.assertResponse('foo', 'bar') + self.assertNotError('aka remove foo') + self.assertError('foo') + + def testDollars(self): + self.assertNotError('aka add rot26 "rot13 [rot13 $1]"') + self.assertResponse('rot26 foobar', 'foobar') + + def testMoreDollars(self): + self.assertNotError('aka add rev "echo $3 $2 $1"') + self.assertResponse('rev foo bar baz', 'baz bar foo') + + def testAllArgs(self): + self.assertNotError('aka add swap "echo $2 $1 $*"') + self.assertResponse('swap 1 2 3 4 5', '2 1 3 4 5') + self.assertError('aka add foo "echo $1 @1 $*"') + self.assertNotError('aka add moo echo $1 $*') + self.assertError('moo') + self.assertResponse('moo foo', 'foo') + self.assertResponse('moo foo bar', 'foo bar') + + def testChannel(self): + self.assertNotError('aka add channel echo $channel') + self.assertResponse('aka channel', self.channel) + + def testNick(self): + self.assertNotError('aka add sendingnick "rot13 [rot13 $nick]"') + self.assertResponse('sendingnick', self.nick) + + def testAddRemoveAka(self): + cb = self.irc.getCallback('Aka') + cb._add_aka(None, 'foobar', 'echo sbbone', lock=True) + self.assertResponse('foobar', 'sbbone') + self.assertRaises(Aka.AkaError, cb.removeAka, 'foobar') + cb._remove_aka(None, 'foobar', evenIfLocked=True) + self.failIf('foobar' in cb.akaes) + self.assertError('foobar') + + def testOptionalArgs(self): + self.assertNotError('aka add myrepr "repr @1"') + self.assertResponse('myrepr foo', '"foo"') + self.assertResponse('myrepr ""', '""') + + def testNoExtraSpaces(self): + self.assertNotError('aka add foo "action takes $1\'s money"') + self.assertResponse('foo bar', '\x01ACTION takes bar\'s money\x01') + + def testNoExtraQuotes(self): + self.assertNotError('aka add myre "echo s/$1/$2/g"') + self.assertResponse('myre foo bar', 's/foo/bar/g') + + def testSimpleAkaWithoutArgsImpliesDollarStar(self): + self.assertNotError('aka add exo echo') + self.assertResponse('exo foo bar baz', 'foo bar baz') + + def testChannelPriority(self): + self.assertNotError('aka add spam "echo foo"') + self.assertNotError('aka add --channel #channel spam "echo bar"') + self.assertResponse('spam', 'bar') + + self.assertNotError('aka add --channel #channel egg "echo baz"') + self.assertNotError('aka add egg "echo qux"') + self.assertResponse('spam', 'baz') + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: