mirror of
https://github.com/Mikaela/Limnoria.git
synced 2025-03-03 04:50:43 +01:00
Add the USING_WRAP and PLUGIN_TUTORIAL docs Strike wrote.
This commit is contained in:
parent
a39555838d
commit
0f41a92069
567
docs/PLUGIN_TUTORIAL
Normal file
567
docs/PLUGIN_TUTORIAL
Normal file
@ -0,0 +1,567 @@
|
||||
WRITING YOUR FIRST SUPYBOT PLUGIN
|
||||
|
||||
Introduction
|
||||
============
|
||||
First things first - what you need to do before writing a Supybot plugin.
|
||||
|
||||
Ok, so you want to write a plugin for Supybot. Good, then this is the place to
|
||||
be. We're going to start from the top (the highest level, where Supybot code
|
||||
does the most work for you) and move lower after that.
|
||||
|
||||
So have you used Supybot? If not, you need to go use it. This will help you
|
||||
understand crucial things like the way the various commands work and it is
|
||||
essential prior to embarking upon the plugin-development excursion detailed in
|
||||
the following pages. If you haven't used Supybot, come back to this document
|
||||
after you've used it for a while and gotten a feel for it.
|
||||
|
||||
So, now that we know you've used Supybot, we'll start getting into details.
|
||||
We'll go through this tutorial by actually writing a new plugin, named Random
|
||||
with just a few simple commands.
|
||||
|
||||
Caveat: you'll need to have Supybot installed on the machine you
|
||||
intend to develop plugins on. This will not only allow you to test
|
||||
the plugins with a live bot, but it will also provide you with
|
||||
several nice scripts which aid the development of plugins. Most
|
||||
notably, it provides you with the supybot-plugin-create script which
|
||||
we will use in the next section... Creating a minimal plugin This
|
||||
section describes using the 'supybot-plugin-create' script to create
|
||||
a minimal plugin which we will enhance in later sections.
|
||||
|
||||
The recommended way to start writing a plugin is to use the wizard provided,
|
||||
'supybot-plugin-create'. Run this from within your local plugins directory, so
|
||||
we will be able to load the plugin and test it out.
|
||||
|
||||
It's very easy to follow, because basically all you have to do is answer three
|
||||
questions. Here's an example session:
|
||||
|
||||
[ddipaolo@quinn ../python/supybot]% supybot-plugin-create
|
||||
What should the name of the plugin be? Random
|
||||
|
||||
Sometimes you'll want a callback to be threaded. If its methods
|
||||
(command or regexp-based, either one) will take a significant amount
|
||||
of time to run, you'll want to thread them so they don't block the
|
||||
entire bot.
|
||||
|
||||
Does your plugin need to be threaded? [y/n] n
|
||||
|
||||
What is your real name, so I can fill in the copyright and license
|
||||
appropriately? Daniel DiPaolo
|
||||
|
||||
Your new plugin template is in the Random directory.
|
||||
|
||||
It's that simple! Well, that part of making the minimal plugin is that simple.
|
||||
You should now have a directory with a few files in it, so let's take a look at
|
||||
each of those files and see what they're used for.
|
||||
|
||||
README.txt
|
||||
==========
|
||||
Tell me about the plugin.
|
||||
|
||||
In README.txt you put exactly what the boilerplate text says to put in there:
|
||||
|
||||
Insert a description of your plugin here, with any notes, etc. about
|
||||
using it.
|
||||
|
||||
A brief overview of exactly what the purpose of the plugin is supposed to do is
|
||||
really all that is needed here. Also, if this plugin requires any third-party
|
||||
Python modules, you should definitely mention those here. You don't have to
|
||||
describe individual commands or anything like that, as those are defined within
|
||||
the plugin code itself as you'll see later. You also don't need to acknowledge
|
||||
any of the developers of the plugin as those too are handled elsewhere.
|
||||
|
||||
For our Random plugin, let's make README.txt say this:
|
||||
|
||||
This plugin contains commands relating to random numbers, and
|
||||
includes: a simple random number generator, the ability to pick a
|
||||
random number from within a range, a command for returning a random
|
||||
sampling from a list of items, and a simple dice roller.
|
||||
|
||||
And now you know what's in store for the rest of this tutorial, we'll be
|
||||
writing all of that in one Supybot plugin, and you'll be surprised at just how
|
||||
simple it is!
|
||||
|
||||
__init__.py
|
||||
===========
|
||||
Plugin properties and a few other bits.
|
||||
|
||||
The next file we'll look at is __init__.py. If you're familiar with the Python
|
||||
import mechanism, you'll know what this file is for. If you're not, think of it
|
||||
as sort of the "glue" file that pulls all the files in this directory together
|
||||
when you load the plugin. It's also where there are a few administrative items
|
||||
live that you really need to maintain.
|
||||
|
||||
Let's go through the file. For the first 30 lines or so, you'll see the
|
||||
copyright notice that we use for our plugins, only with your name in place (as
|
||||
prompted in 'supybot-plugin-create'). Feel free to use whatever license you
|
||||
choose, we don't feel particularly attached to the boilerplate code so it's
|
||||
yours to license as you see fit even if you don't modify it. For our example,
|
||||
we'll leave it as is.
|
||||
|
||||
The plugin docstring immediately follows the copyright notice and it (like
|
||||
README.txt) tells you precisely what it should contain:
|
||||
|
||||
Add a description of the plugin (to be presented to the user inside
|
||||
the wizard) here. This should describe *what* the plugin does.
|
||||
|
||||
The "wizard" that it speaks of is the 'supybot-wizard' script that is used to
|
||||
create working Supybot config file. I imagine that in meeting the prerequisite
|
||||
of "using a Supybot" first, most readers will have already encountered this
|
||||
script. Basically, if the user selects to look at this plugin from the list of
|
||||
plugins to load, it prints out that description to let the user know what it
|
||||
does, so make sure to be clear on what the purpose of the plugin is. This
|
||||
should be an abbreviated version of what we put in our README.txt, so let's put
|
||||
this:
|
||||
|
||||
Provides a number of commands for selecting random things.
|
||||
|
||||
Next in __init__.py you see a few imports which are necessary, and then four
|
||||
attributes that you need to modify for your bot and preferably keep up with as
|
||||
you develop it: __version__, __author__, __contributors__, __url__.
|
||||
|
||||
__version__ is just a version string representing the current working version
|
||||
of the plugin, and can be anything you want. If you use some sort of RCS, this
|
||||
would be a good place to have it automatically increment the version string for
|
||||
any time you edit any of the files in this directory. We'll just make ours
|
||||
"0.1".
|
||||
|
||||
__author__ should be an instance of the supybot.Author class. A supybot.Author
|
||||
is simply created by giving it a full name, a short name (preferably IRC nick),
|
||||
and an e-mail address (all of these are optional, though at least the second
|
||||
one is expected). So, for example, to create my Author user (though I get to
|
||||
cheat and use supybot.authors.strike since I'm a main dev, muahaha), I would
|
||||
do:
|
||||
|
||||
__author__ = supybot.Author('Daniel DiPaolo', 'Strike',
|
||||
'somewhere@someplace.xxx')
|
||||
|
||||
Keep this in mind as we get to the next item...
|
||||
|
||||
__contributors__ is a dictionary mapping supybot.Author instances to lists of
|
||||
things they contributed. If someone adds a command named foo to your plugin,
|
||||
the list for that author should be ["foo"], or perhaps even ["added foo
|
||||
command"]. The main author shouldn't be referenced here, as it is assumed that
|
||||
everything that wasn't contributed my someone else was done by the main author.
|
||||
For now we have no contributors, so we'll leave it blank.
|
||||
|
||||
Lastly, the __url__ attribute should just reference the download URL for the
|
||||
plugin. We encourage you to use the supybot.com website for distributing
|
||||
plugins and have gone to great lengths to make distributing them nice and easy
|
||||
to do so. Since this is just an example, we'll leave this blank, but
|
||||
supybot-plugin-create shows an example URL of a plugin home here on supybot.com
|
||||
|
||||
The rest of __init__.py really shouldn't be touched unless you are using
|
||||
third-party modules in your plugin. If you are, then you need to take special
|
||||
note of the section that looks like this:
|
||||
|
||||
import config
|
||||
import plugin
|
||||
reload(plugin) # In case we're being reloaded.
|
||||
# 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!
|
||||
|
||||
As the comment says, this is one place where you need to make sure you import
|
||||
the third-party modules, and that you call reload() on them as well. That way,
|
||||
if we are reloading a plugin on a running bot it will actually reload the
|
||||
latest code. We aren't using any third-party modules, so we can just leave this
|
||||
bit alone.
|
||||
|
||||
We're almost through the "boring" part and into the guts of writing Supybot
|
||||
plugins, let's take a look at the next file.
|
||||
|
||||
config.py
|
||||
=========
|
||||
Making our plugin configurable
|
||||
|
||||
config.py is, unsurprisingly, where all the configuration stuff related to
|
||||
your plugin goes. If you're not familiar with Supybot's configuration system,
|
||||
I recommend reading the config tutorial before going any further with this
|
||||
section.
|
||||
|
||||
So, let's plow through config.py line-by-line like we did the other files.
|
||||
|
||||
Once again, at the top is the standard copyright notice. Again, change it to
|
||||
how you see fit.
|
||||
|
||||
Then, some standard imports which are necessary.
|
||||
|
||||
Now, the first peculiar thing we get to is the configure function. This
|
||||
function is what is called by the supybot-wizard whenever a plugin is selected
|
||||
to be loaded. Since you've used the bot by now (as stated on the first page of
|
||||
this tutorial as a prerequisite), you've seen what this script does to
|
||||
configure plugins. The wizard allows the bot owner to choose something
|
||||
different from the default plugin config values without having to do it through
|
||||
the bot (which is still not difficult, but not as easy as this). Also, note
|
||||
that the advanced argument allows you to differentiate whether or not the
|
||||
person configuring this plugin considers himself an advanced Supybot user. Our
|
||||
plugin has no advanced features, so we won't be using it.
|
||||
|
||||
So, what exactly do we do in this configure function for our plugin? Well, for
|
||||
the most part we ask questions and we set configuration values. You'll notice
|
||||
the import line with supybot.questions in it. That provides some nice
|
||||
convenience functions which are used to (you guessed it) ask questions. The
|
||||
other line in there is the conf.registerPlugin line which registers our plugin
|
||||
with the config and allows us to create configuration values for the plugin.
|
||||
You should leave these two lines in even if you don't have anything else to put
|
||||
in here. For the vast majority of plugins, you can leave this part as is, so we
|
||||
won't go over how to write plugin configuration functions here (that will be
|
||||
handled in a separate article). Our plugin won't be using much configuration,
|
||||
so we'll leave this as is.
|
||||
|
||||
Next, you'll see a line that looks very similar to the one in the configure
|
||||
function. This line is used not only to register the plugin prior to being
|
||||
called in configure, but also to store a bit of an alias to the plugin's config
|
||||
group to make things shorter later on. So, this line should read:
|
||||
|
||||
Random = conf.registerPlugin('Random')
|
||||
|
||||
Now we get to the part where we define all the configuration groups and
|
||||
variables that our plugin is to have. Again, many plugins won't require any
|
||||
configuration so we won't go over it here, but in a separate article dedicated
|
||||
to sprucing up your config.py for more advanced plugins. Our plugin doesn't
|
||||
require any config variables, so we actually don't need to make any changes to
|
||||
this file at all.
|
||||
|
||||
Configuration of plugins is handled in depth at the Advanced Plugin Config
|
||||
Tutorial
|
||||
|
||||
plugin.py
|
||||
=========
|
||||
The meat of the plugin
|
||||
|
||||
Here's the moment you've been waiting for, the overview of plugin.py and how to
|
||||
make our plugin actually do stuff.
|
||||
|
||||
At the top, same as always, is the standard copyright block to be used and
|
||||
abused at your leisure.
|
||||
|
||||
Next, some standard imports. Not all of them are used at the moment, but you
|
||||
probably will use many (if not most) of them, so just let them be.
|
||||
|
||||
Now, the plugin class itself. What you're given is a skeleton: a simple
|
||||
subclass of callbacks.Plugin for you to start with. The only real content it
|
||||
has is the boilerplate docstring, which you should modify to reflect what the
|
||||
boilerplate text says - it should be useful so that when someone uses the
|
||||
plugin help command to determine how to use this plugin, they'll know what they
|
||||
need to do. Ours will read something like:
|
||||
|
||||
"""This plugin provides a few random number commands and some
|
||||
commands for getting random samples. Use the "seed" command to seed
|
||||
the plugin's random number generator if you like, though it is
|
||||
unnecessary as it gets seeded upon loading of the plugin. The
|
||||
"random" command is most likely what you're looking for, though
|
||||
there are a number of other useful commands in this plugin. Use
|
||||
'list random' to check them out. """
|
||||
|
||||
It's basically a "guide to getting started" for the plugin. Now, to make the
|
||||
plugin do something. First of all, to get any random numbers we're going to
|
||||
need a random number generator (RNG). Pretty much everything in our plugin is
|
||||
going to use it, so we'll define it in the constructor of our plugin, __init__.
|
||||
Here we'll also seed it with the current time (standard practice for RNGs).
|
||||
Here's what our __init__ looks like:
|
||||
|
||||
def __init__(self, irc):
|
||||
self.__parent = super(Random, self)
|
||||
self.__parent.__init__(irc)
|
||||
self.rng = random.Random() # create our rng
|
||||
self.rng.seed() # automatically seeds with current time
|
||||
|
||||
Now, the first two lines may look a little daunting, but it's just
|
||||
administrative stuff required if you want to use a custom __init__. If we
|
||||
didn't want to do so, we wouldn't have to, but it's not uncommon so I decided
|
||||
to use an example plugin that did. For the most part you can just copy/paste
|
||||
those lines into any plugin you override the __init__ for and just change them
|
||||
to use the plugin name that you are working on instead.
|
||||
|
||||
So, now we have a RNG in our plugin, let's write a command to get a random
|
||||
number. We'll start with a simple command named random that just returns a
|
||||
random number from our RNG and takes no arguments. Here's what that looks like:
|
||||
|
||||
def random(self, irc, msg, args):
|
||||
"""takes no arguments
|
||||
|
||||
Returns the next random number from the random number generator.
|
||||
"""
|
||||
irc.reply(str(self.rng.random()))
|
||||
random = wrap(random)
|
||||
|
||||
And that's it. Now here are the important points.
|
||||
|
||||
First and foremost, all plugin commands must be have all-lowercase function
|
||||
names. If they aren't all lowercase they won't show up in a plugin's list of
|
||||
commands (nor will they be useable in general). If you look through a plugin
|
||||
and see a function that's not in all lowercase, it is not a plugin command.
|
||||
Chances are it is a helper function of some sort, and in fact using capital
|
||||
letters is a good way of assuring that you don't accidentally expose helper
|
||||
functions to users as commands.
|
||||
|
||||
You'll note the arguments to this class method are (self, irc, msg, args). This
|
||||
is what the argument list for all methods that are to be used as commands must
|
||||
start with. If you wanted additional arguments, you'd append them onto the end,
|
||||
but since we take no arguments we just stop there. I'll explain this in more
|
||||
detail with our next command, but it is very important that all plugin commands
|
||||
are class methods that start with those four arguments exactly as named.
|
||||
|
||||
Next, in the docstring there are two major components. First, the very first
|
||||
line dictates the argument list to be displayed when someone calls the help
|
||||
command for this command (i.e., help random). Then you leave a blank line and
|
||||
start the actual help string for the function. Don't worry about the fact that
|
||||
it's tabbed in or anything like that, as we the help command normalizes it to
|
||||
make it look nice. This part should be fairly brief but sufficient to explain
|
||||
the function and what (if any) arguments it requires. Remember that this should
|
||||
fit in one IRC message which is typically around a 450 character limit.
|
||||
|
||||
Then we have the actual code body of the plugin, which consists of a single
|
||||
line: irc.reply(str(self.rng.random())). The irc.reply function issues a reply
|
||||
to wherever the PRIVMSG it received the command from with whatever text is
|
||||
provided. If you're not sure what I mean when I say "wherever the PRIVMSG it
|
||||
received the command from", basically it means: if the command is issued in a
|
||||
channel the response is sent in the channel, and if the command is issued in a
|
||||
private dialog the response is sent in a private dialog. The text we want to
|
||||
display is simply the next number from our RNG (self.rng). We get that number
|
||||
by calling the random function, and then we str it just to make sure it is a
|
||||
nice printable string.
|
||||
|
||||
Lastly, all plugin commands must be 'wrap'ed. What the wrap function does is
|
||||
handle argument parsing for plugin commands in a very nice and very powerful
|
||||
way. With no arguments, we simply need to just wrap it. For more in-depth
|
||||
information on using wrap check out the wrap tutorial (The astute Python
|
||||
programmer may note that this is very much like a decorator, and that's
|
||||
precisely what it is. However rather than use the @decorator syntax from Python
|
||||
2.4 we elect to stick with this syntax to remain compatible with Python 2.3.)
|
||||
|
||||
Now let's create a command with some arguments and see how we use those in our
|
||||
plugin commands. Let's allow the user to seed our RNG with their own seed
|
||||
value. We'll call the command seed and take just the seed value as the argument
|
||||
(which we'll require be a floating point value of some sort, though technically
|
||||
it can be any hashable object). Here's what this command looks like:
|
||||
|
||||
def seed(self, irc, msg, args, seed):
|
||||
"""<seed>
|
||||
|
||||
Sets the internal RNG's seed value to <seed>. <seed> must be a
|
||||
floating point number.
|
||||
"""
|
||||
self.rng.seed(seed)
|
||||
irc.replySuccess()
|
||||
seed = wrap(seed, ['float'])
|
||||
|
||||
You'll notice first that argument list now includes an extra argument, seed. If
|
||||
you read the wrap tutorial linked above, you should understand how this arg
|
||||
list gets populated with values. Thanks to wrap we don't have to worry about
|
||||
type-checking or value-checking or anything like that. We just specify that it
|
||||
must be a float in the wrap portion and we can use it in the body of the
|
||||
function.
|
||||
|
||||
Of course, we modify the docstring to document this function. Note the syntax
|
||||
on the first line. Arguments go in <> and optional arguments should be
|
||||
surrounded by [] (we'll demonstrate this later as well).
|
||||
|
||||
The body of the function should be fairly straightforward to figure out, but it
|
||||
introduces a new function - irc.replySuccess. This is just a generic "I
|
||||
succeeded" command which responds with whatever the bot owner has configured to
|
||||
be the success response (configured in supybot.replies.success). Note that we
|
||||
don't do any error-checking in the plugin, and that's because we simply don't
|
||||
have to. We are guaranteed that seed will be a float and so the call to our
|
||||
RNG's seed is guaranteed to work.
|
||||
|
||||
Lastly, of course, the wrap call. Again, read the wrap tutorial for fuller
|
||||
coverage of its use, but the basic premise is that the second argument to wrap
|
||||
is a list of converters that handles argument validation and conversion and it
|
||||
then assigns values to each argument in the arg list after the first four
|
||||
(required) arguments. So, our seed argument gets a float, guaranteed.
|
||||
|
||||
With this alone you'd be able to make some pretty usable plugin commands, but
|
||||
we'll go through two more commands to introduce a few more useful ideas. The
|
||||
next command we'll make is a sample command which gets a random sample of items
|
||||
from a list provided by the user:
|
||||
|
||||
def sample(self, irc, msg, args, n, items):
|
||||
"""<number of items> <item1> [<item2> ...]
|
||||
|
||||
Returns a sample of the <number of items> taken from the remaining
|
||||
arguments. Obviously <number of items> must be less than the number
|
||||
of arguments given.
|
||||
"""
|
||||
if n > len(items):
|
||||
irc.error('<number of items> must be less than the number '
|
||||
'of arguments.')
|
||||
return
|
||||
sample = self.rng.sample(items, n)
|
||||
sample.sort()
|
||||
irc.reply(utils.str.commaAndify(sample))
|
||||
sample = wrap(sample, ['int', many('anything')])
|
||||
|
||||
This plugin command introduces a few new things, but the general structure
|
||||
should look fairly familiar by now. You may wonder why we only have two extra
|
||||
arguments when obviously this plugin can accept any number of arguments. Well,
|
||||
using wrap we collect all of the remaining arguments after the first one into
|
||||
the items argument. If you haven't caught on yet, wrap is really cool and
|
||||
extremely useful.
|
||||
|
||||
Next of course is the updated docstring. Note the use of [] to denote the
|
||||
optional items after the first item.
|
||||
|
||||
The body of the plugin should be relatively easy to read. First we check and
|
||||
make sure that n (the number of items the user wants to sample) is not larger
|
||||
than the actual number of items they gave. If it does, we call irc.error with
|
||||
the error message you see. irc.error is kind of like irc.replySuccess only it
|
||||
gives an error message using the configured error format (in
|
||||
supybot.replies.error). Otherwise, we use the sample function from our RNG to
|
||||
get a sample, then we sort it, and we reply with the 'utils.str.commaAndify'ed
|
||||
version. The utils.str.commaAndify function basically takes a list of strings
|
||||
and turns it into "item1, item2, item3, item4, and item5" for an arbitrary
|
||||
length. More details on using the utils module can be found in the utils
|
||||
tutorial.
|
||||
|
||||
Now for the last command that we will add to our plugin.py. This last command
|
||||
will allow the bot users to roll an arbitrary n-sided die, with as many sides
|
||||
as they so choose. Here's the code for this command:
|
||||
|
||||
def diceroll(self, irc, msg, args, n):
|
||||
"""[<number of sides>]
|
||||
|
||||
Rolls a die with <number of sides> sides. The default number of sides
|
||||
is 6.
|
||||
"""
|
||||
s = 'rolls a %s' % self.rng.randrange(1, n)
|
||||
irc.reply(s, action=True)
|
||||
diceroll = wrap(diceroll, [additional(('int', 'number of sides'), 6)])
|
||||
|
||||
The only new thing learned here really is that the irc.reply method accepts an
|
||||
optional argument action, which if set to True makes the reply an action
|
||||
instead. So instead of just crudely responding with the number, instead you
|
||||
should see something like * supybot rolls a 5. You'll also note that it uses a
|
||||
more advanced wrap line than we have used to this point, but to learn more
|
||||
about wrap, you should refer to the wrap tutorial
|
||||
|
||||
And now that we're done adding plugin commands you should see the boilerplate
|
||||
stuff at the bottom, which just consists of:
|
||||
|
||||
Class = Random
|
||||
|
||||
And also some vim modeline stuff. Leave these as is, and we're finally done
|
||||
with plugin.py!
|
||||
|
||||
test.py
|
||||
=======
|
||||
Plugin tests go here.
|
||||
|
||||
Now that we've gotten our plugin written, we want to make sure it works. Sure,
|
||||
an easy way to do a somewhat quick check is to start up a bot, load the plugin,
|
||||
and run a few commands on it. If all goes well there, everything's probably
|
||||
okay. But, we can do better than "probably okay". This is where written plugin
|
||||
tests come in. We can write tests that not only assure that the plugin loads
|
||||
and runs the commands fine, but also that it produces the expected output for
|
||||
given inputs. And not only that, we can use the nifty supybot-test script to
|
||||
test the plugin without even having to have a network connection to connect to
|
||||
IRC with and most certainly without running a local IRC server.
|
||||
|
||||
The boilerplate code for test.py is a good start. It imports everything you
|
||||
need and sets up RandomTestCase which will contain all of our tests. Now we
|
||||
just need to write some test methods. I'll be moving fairly quickly here just
|
||||
going over very basic concepts and glossing over details, but the full plugin
|
||||
test authoring tutorial has much more detail to it and is recommended reading
|
||||
after finishing this tutorial.
|
||||
|
||||
Since we have four commands we should have at least four test methods in our
|
||||
test case class. Typically you name the test methods that simply checks that a
|
||||
given command works by just appending the command name to test. So, we'll have
|
||||
testRandom, testSeed, testSample, and testDiceRoll. Any other methods you want
|
||||
to add are more free-form and should describe what you're testing (don't be
|
||||
afraid to use long names).
|
||||
|
||||
First we'll write the testRandom method:
|
||||
|
||||
def testRandom(self):
|
||||
# difficult to test, let's just make sure it works
|
||||
self.assertNotError('random')
|
||||
|
||||
Since we can't predict what the output of our random number generator is going
|
||||
to be, it's hard to specify a response we want. So instead, we just make sure
|
||||
we don't get an error by calling the random command, and that's about all we
|
||||
can do.
|
||||
|
||||
Next, testSeed. In this method we're just going to check that the command
|
||||
itself functions. In another test method later on we will check and make sure
|
||||
that the seed produces reproducible random numbers like we would hope it would,
|
||||
but for now we just test it like we did random in 'testRandom':
|
||||
|
||||
def testSeed(self):
|
||||
# just make sure it works
|
||||
self.assertNotError('seed 20')
|
||||
|
||||
Now for testSample. Since this one takes more arguments it makes sense that we
|
||||
test more scenarios in this one. Also this time we have to make sure that we
|
||||
hit the error that we coded in there given the right conditions:
|
||||
|
||||
def testSample(self):
|
||||
self.assertError('sample 20 foo')
|
||||
self.assertResponse('sample 1 foo', 'foo')
|
||||
self.assertRegexp('sample 2 foo bar', '... and ...')
|
||||
self.assertRegexp('sample 3 foo bar baz', '..., ..., and ...')
|
||||
|
||||
So first we check and make sure trying to take a 20-element sample of a
|
||||
1-element list gives us an error. Next we just check and make sure we get the
|
||||
right number of elements and that they are formatted correctly when we give 1,
|
||||
2, or 3 element lists.
|
||||
|
||||
And for the last of our basic "check to see that it works" functions,
|
||||
testDiceRoll:
|
||||
|
||||
def testDiceRoll(self):
|
||||
self.assertActionRegexp('diceroll', 'rolls a \d')
|
||||
|
||||
We know that diceroll should return an action, and that with no arguments it
|
||||
should roll a single-digit number. And that's about all we can test reliably
|
||||
here, so that's all we do.
|
||||
|
||||
Lastly, we wanted to check and make sure that seeding the RNG with seed
|
||||
actually took effect like it's supposed to. So, we write another test method:
|
||||
|
||||
def testSeedActuallySeeds(self):
|
||||
# now to make sure things work repeatably
|
||||
self.assertNotError('seed 20')
|
||||
m1 = self.getMsg('random')
|
||||
self.assertNotError('seed 20')
|
||||
m2 = self.getMsg('random')
|
||||
self.failUnlessEqual(m1, m2)
|
||||
m3 = self.getMsg('random')
|
||||
self.failIfEqual(m2, m3)
|
||||
|
||||
So we seed the RNG with 20, store the message, and then seed it at 20 again. We
|
||||
grab that message, and unless they are the same number when we compare the two,
|
||||
we fail. And then just to make sure our RNG is producing random numbers, we get
|
||||
another random number and make sure it is distinct from the prior one.
|
||||
|
||||
Conclusion
|
||||
==========
|
||||
Now you're ready to write Supybot plugins!
|
||||
|
||||
You are now very well-prepared to write Supybot plugins. Now for a few words of
|
||||
wisdom with regards to Supybot plugin-writing.
|
||||
|
||||
* Read other people's plugins, especially the included plugins and ones by
|
||||
the core developers. We (the Supybot dev team) can't possibly document
|
||||
all the awesome things that Supybot plugins can do, but we try.
|
||||
Nevertheless there are some really cool things that can be done that
|
||||
aren't very well-documented on this site.
|
||||
|
||||
* Hack new functionality into existing plugins first if writing a new
|
||||
plugin is too daunting.
|
||||
|
||||
* Come ask us questions in #supybot on Freenode or OFTC. Going back to the
|
||||
first point above, the developers themselves can help you even more than
|
||||
the docs can (though we prefer you read the docs first).
|
||||
|
||||
* Publish your plugins on this website. We made some custom stuff on this
|
||||
website just to cater to publishing plugins. Use it, share your plugins
|
||||
with the world and make Supybot all that more attractive for other users
|
||||
so they will want to write their plugins for Supybot as well.
|
||||
|
||||
* Read, read, read all the documentation on this website. I just spent a
|
||||
lot of time getting a bunch of these tutorials out and getting things up
|
||||
to date, so while Supybot documentation in the past hasn't been stellar,
|
||||
it certainly is very good now.
|
||||
|
||||
* And of course, have fun writing your plugins.
|
433
docs/USING_WRAP
Normal file
433
docs/USING_WRAP
Normal file
@ -0,0 +1,433 @@
|
||||
Using the commands.wrap to parse your command's arguments.
|
||||
----------------------------------------------------------
|
||||
This document illustrates how to use the new 'wrap' function present in Supybot
|
||||
0.80 to handle argument parsing and validation for your plugin's commands.
|
||||
|
||||
Introduction
|
||||
============
|
||||
What is 'wrap'?
|
||||
|
||||
To plugin developers for older (pre-0.80) versions of Supybot, one of the more
|
||||
annoying aspects of writing commands was handling the arguments that were
|
||||
passed in. In fact, many commands often had to duplicate parsing and
|
||||
verification code, resulting in lots of duplicated code for not a whole lot of
|
||||
action. So, instead of forcing plugin writers to come up with their own ways of
|
||||
cleaning it up, we wrote up the wrap function to handle all of it.
|
||||
|
||||
It allows a much simpler and more flexible way of checking things than before
|
||||
and it doesn't require that you know the bot internals to do things like check
|
||||
and see if a user exists, or check if a command name exists and whatnot.
|
||||
|
||||
If you are a plugin author this document is absolutely required reading, as it
|
||||
will massively ease the task of writing commands.
|
||||
|
||||
Using Wrap
|
||||
==========
|
||||
How can I use 'wrap'?
|
||||
|
||||
First off, to get the wrap function, it is recommended (strongly) that you use
|
||||
the following import line:
|
||||
|
||||
from supybot.commands import *
|
||||
|
||||
This will allow you to access the wrap command (and it allows you to do it
|
||||
without the commands prefix. Note that this line is added to the imports of
|
||||
plugin templates generated by the supybot-newplugin script.
|
||||
|
||||
Let's write a quickie command that uses wrap to get a feel for how it makes our
|
||||
lives better. Let's write a command that repeats a string of text a given
|
||||
number of times. So you could say "repeat 3 foo" and it would say "foofoofoo".
|
||||
Not a very useful command, but it will serve our purpose just fine. Here's how
|
||||
it would be done without wrap:
|
||||
|
||||
def repeat(self, irc, msg, args):
|
||||
"""<num> <text>
|
||||
|
||||
Repeats <text> <num> times.
|
||||
"""
|
||||
(num, text) = privmsg.getArgs(args, required=2)
|
||||
try:
|
||||
num = int(num)
|
||||
except ValueError:
|
||||
raise callbacks.ArgumentError
|
||||
return
|
||||
irc.reply(num * text)
|
||||
|
||||
Note that all of the argument validation and parsing takes up 6 of the 7 lines
|
||||
(and you should have seen it before we had privmsg.getArgs!). Now, here's what
|
||||
our command will look like with wrap applied:
|
||||
|
||||
def repeat(self, irc, msg, args, num, text):
|
||||
"""<num> <text>
|
||||
|
||||
Repeats <text> <num> times.
|
||||
"""
|
||||
irc.reply(text * num)
|
||||
repeat = wrap(repeat, ['int', 'text'])
|
||||
|
||||
Pretty short, eh? With wrap all of the argument parsing and validation is
|
||||
handled for us and we get the arguments we want, formatted how we want them,
|
||||
and converted into whatever types we want them to be - all in one simple
|
||||
function call that is used to wrap the function! So now the code inside each
|
||||
command really deals with how to execute the command and not how to deal with
|
||||
the input.
|
||||
|
||||
So, now that you see the benefits of wrap, let's figure out what stuff we have
|
||||
to do to use it.
|
||||
|
||||
Syntax Changes
|
||||
==============
|
||||
How will I need to change my plugin commands to take advantage of 'wrap'?
|
||||
|
||||
There are two syntax changes to the old style that are implemented. First, the
|
||||
definition of the command function must be changed. The basic syntax for the
|
||||
new definition is:
|
||||
|
||||
def commandname(self, irc, msg, args, <arg1>, <arg2>, ...):
|
||||
|
||||
Where arg1 and arg2 (up through as many as you want) are the variables that
|
||||
will store the parsed arguments. "Now where do these parsed arguments come
|
||||
from?" you ask. Well, that's where the second syntax change comes in. The
|
||||
second syntax change is the actual use of the wrap function itself to decorate
|
||||
our command names. The basic decoration syntax is:
|
||||
|
||||
commandname = wrap(commandname, [converter1, converter2, ...])
|
||||
|
||||
NOTE: This should go on the line immediately following the body of the
|
||||
command's definition, so it can easily be located (and it obviously must go
|
||||
after the command's definition so that commandname is defined).
|
||||
|
||||
Each of the converters in the above listing should be one of the converters in
|
||||
commands.py (I will describe each of them in detail later.) The converters are
|
||||
applied in order to the arguments given to the command, generally taking
|
||||
arguments off of the front of the argument list as they go. Note that each of
|
||||
the arguments is actually a string containing the NAME of the converter to use
|
||||
and not a reference to the actual converter itself. This way we can have
|
||||
converters with names like int and not have to worry about polluting the
|
||||
builtin namespace by overriding the builtin int.
|
||||
|
||||
As you will find out when you look through the list of converters below, some
|
||||
of the converters actually take arguments. The syntax for supplying them (since
|
||||
we aren't actually calling the converters, but simply specifying them), is to
|
||||
wrap the converter name and args list into a tuple. For example:
|
||||
|
||||
commandname = wrap(commandname, [(converterWithArgs, arg1, arg2),
|
||||
converterWithoutArgs1, converterWithoutArgs2])
|
||||
|
||||
For the most part you won't need to use an argument with the converters you use
|
||||
either because the defaults are satisfactory or because it doesn't even take
|
||||
any.
|
||||
|
||||
Customizing Wrap
|
||||
================
|
||||
Is there a way to affect how I apply my wraps? We call them contexts.
|
||||
|
||||
Converters alone are a pretty powerful tool, but for even more advanced (yet
|
||||
simpler!) argument handling you may want to use contexts. Contexts describe how
|
||||
the converters are applied to the arguments, while the converters themselves
|
||||
actually do the actual parsing and validation.
|
||||
|
||||
For example, one of the contexts is "optional". By using this context, you're
|
||||
saying that a given argument is not required, and if the supplied converter
|
||||
doesn't find something it likes, we should use some default. Yet another
|
||||
example is the "reverse" context. This context tells the supplied converter to
|
||||
look at the last argument and work backwards instead of the normal
|
||||
first-to-last way of looking at arguments.
|
||||
|
||||
So, that should give you a feel for the role that contexts play. They are not
|
||||
by any means necessary to use wrap. All of the stuff we've done to this point
|
||||
will work as-is. However, contexts let you do some very powerful things in very
|
||||
easy ways, and are a good thing to know how to use.
|
||||
|
||||
Now, how do you use them? Well, they are in the global namespace of
|
||||
src/commands.py, so your previous import line will import them all, so you can
|
||||
call them just as you call wrap. In fact, the way you use them is you simply
|
||||
call the context function you want to use, passing in the converter and its
|
||||
arguments in as arguments. It's quite simple. Here's an example:
|
||||
|
||||
commandname = wrap(commandname, [optional('int'), many('something')])
|
||||
|
||||
In this example, our command is looking for an optional integer argument first. Then, after that, any number of arguments which can be anything (as long as they are something, of course).
|
||||
|
||||
Do note, however, that the type of the arguments that are returned can be
|
||||
changed if you apply a context to it. So, optional("int") may very well return
|
||||
None as well as something that passes the "int" converter, because after all
|
||||
it's an optional argument and if it is None, that signifies that nothing was
|
||||
there. Also, for another example, many("something") doesn't return the same
|
||||
thing that just "something" would return, but rather a list of "something"s.
|
||||
|
||||
Converter List
|
||||
==============
|
||||
What converters are available for me to use?
|
||||
|
||||
Below is a list of all the available converters to use with wrap. If the
|
||||
converter accepts any arguments, they are listed after it and if they are
|
||||
optional, the default value is shown.
|
||||
|
||||
"id", kind="integer"
|
||||
Returns something that looks like an integer ID number. Takes an optional
|
||||
"kind" argument for you to state what kind of ID you are looking for,
|
||||
though this doesn't affect the integrity-checking. Basically requires that
|
||||
the argument be an integer, does no other integrity-checking, and provides
|
||||
a nice error message with the kind in it.
|
||||
|
||||
"ip"
|
||||
Checks and makes sure the argument looks like a valid IP and then returns
|
||||
it.
|
||||
|
||||
"int", type="integer", p=None
|
||||
Gets an integer. The "type" text can be used to customize the error message
|
||||
received when the argument is not an integer. p is an optional predicate to
|
||||
test the integer with. If p(i) fails (where i is the integer arg parsed out
|
||||
of the argument string), the arg will not be accepted.
|
||||
|
||||
"index"
|
||||
Basically ("int", "index"), but with a twist. This will take a 1-based
|
||||
index and turn it into a 0-based index (which is more useful in code). It
|
||||
doesn't transform 0, and it maintains negative indices as is (note that it
|
||||
does allow them!).
|
||||
|
||||
"color"
|
||||
Accepts arguments that describe a text color code (e.g., "black", "light
|
||||
blue") and returns the mIRC color code for that color. (Note that many
|
||||
other IRC clients support the mIRC color code scheme, not just mIRC)
|
||||
|
||||
"now"
|
||||
Simply returns the current timestamp as an arg, does not reference or
|
||||
modify the argument list.
|
||||
|
||||
"url"
|
||||
Checks for a valid URL.
|
||||
|
||||
"httpUrl"
|
||||
Checks for a valid HTTP URL.
|
||||
|
||||
"long", type="long"
|
||||
Basically the same as int minus the predicate, except that it converts the
|
||||
argument to a long integer regardless of the size of the int.
|
||||
|
||||
"float", type="floating point number"
|
||||
Basically the same as int minus the predicate, except that it converts the
|
||||
argument to a float.
|
||||
|
||||
"nonInt", type="non-integer value"
|
||||
Accepts everything but integers, and returns them unchanged. The "type"
|
||||
value, as always, can be used to customize the error message that is
|
||||
displayed upon failure.
|
||||
|
||||
"positiveInt"
|
||||
Accepts only positive integers.
|
||||
|
||||
"nonNegativeInt"
|
||||
Accepts only non-negative integers.
|
||||
|
||||
"letter"
|
||||
Looks for a single letter. (Technically, it looks for any one-element
|
||||
sequence)
|
||||
|
||||
"haveOp", action="do that"
|
||||
Simply requires that the bot have ops in the channel that the command is
|
||||
called in. The action parameter completes the error message: "I need to be
|
||||
opped to ...".
|
||||
|
||||
"expiry"
|
||||
Takes a number of seconds and adds it to the current time to create an
|
||||
expiration timestamp.
|
||||
|
||||
"literal", literals, errmsg=None
|
||||
Takes a required sequence or string (literals) and any argument that
|
||||
uniquely matches the starting substring of one of the literals is
|
||||
transformed into the full literal. For example, with ("literal", ("bar",
|
||||
"baz", "qux")), you'd get "bar" for "bar", "baz" for "baz", and "qux" for
|
||||
any of "q", "qu", or "qux". "b" and "ba" would raise errors because they
|
||||
don't uniquely identify one of the literals in the list. You can override
|
||||
errmsg to provide a specific (full) error message, otherwise the default
|
||||
argument error message is displayed.
|
||||
|
||||
"to"
|
||||
Returns the string "to" if the arg is any form of "to" (case-insensitive).
|
||||
|
||||
"nick"
|
||||
Checks that the arg is a valid nick on the current IRC server.
|
||||
|
||||
"seenNick"
|
||||
Checks that the arg is a nick that the bot has seen (NOTE: this is limited
|
||||
by the size of the history buffer that the bot has)
|
||||
|
||||
"channel"
|
||||
Gets a channel to use the command in. If the channel isn't supplied, uses
|
||||
the channel the message was sent in. If using a different channel, does
|
||||
sanity-checking to make sure the channel exists on the current IRC network.
|
||||
|
||||
"inChannel"
|
||||
Requires that the command be called from within any channel that the bot is
|
||||
currently in.
|
||||
|
||||
"nickInChannel"
|
||||
Requires that the argument be a nick that is in the current channel, and
|
||||
returns that nick.
|
||||
|
||||
"networkIrc", errorIfNoMatch=False
|
||||
Returns the IRC object of the specified IRC network. If one isn't
|
||||
specified, the IRC object of the IRC network the command was called on is
|
||||
returned.
|
||||
|
||||
"callerInGivenChannel"
|
||||
Takes the given argument as a channel and makes sure that the caller is in
|
||||
that channel.
|
||||
|
||||
"plugin", require=True
|
||||
Returns the plugin specified by the arg or None. If require is True, an
|
||||
error is raised if the plugin cannot be retrieved.
|
||||
|
||||
"boolean"
|
||||
Converts the text string to a boolean value. Acceptable true values are:
|
||||
"1", "true", "on", "enable", or "enabled" (case-insensitive). Acceptable
|
||||
false values are: "0", false", "off", "disable", or "disabled"
|
||||
(case-insensitive).
|
||||
|
||||
"lowered"
|
||||
Returns the argument lowered (NOTE: it is lowered according to IRC
|
||||
conventions, which does strange mapping with some punctuation characters).
|
||||
|
||||
"anything"
|
||||
Returns anything as is.
|
||||
|
||||
"something", errorMsg=None, p=None
|
||||
Takes anything but the empty string. errorMsg can be used to customize the
|
||||
error message. p is any predicate function that can be used to test the
|
||||
validity of the input.
|
||||
|
||||
"filename"
|
||||
Used to get a filename argument.
|
||||
|
||||
"commandName"
|
||||
Returns the canonical command name version of the given string (ie, the
|
||||
string is lowercased and dashes and underscores are removed).
|
||||
|
||||
"text"
|
||||
Takes the rest of the arguments as one big string. Note that this differs
|
||||
from the "anything" context in that it clobbers the arg string when it's
|
||||
done. Using any converters after this is most likely incorrect.
|
||||
|
||||
"glob"
|
||||
Gets a glob string. Basically, if there are no wildcards ('"*"', "?") in
|
||||
the argument, returns *string*, making a glob string that matches anything
|
||||
containing the given argument.
|
||||
|
||||
"somethingWithoutSpaces"
|
||||
Same as something, only with the exception of disallowing spaces of course.
|
||||
|
||||
"capability"
|
||||
Used to retrieve an argument that describes a capability.
|
||||
|
||||
"channelDb"
|
||||
Sets the channel appropriately in order to get to the databases for that
|
||||
channel (handles whether or not a given channel uses channel-specific
|
||||
databases and whatnot).
|
||||
|
||||
"hostmask"
|
||||
Returns the hostmask of any provided nick or hostmask argument.
|
||||
|
||||
"banmask"
|
||||
Returns a generic banmask of the provided nick or hostmask argument.
|
||||
|
||||
"user"
|
||||
Requires that the caller be a registered user.
|
||||
|
||||
"matches", regexp, errmsg
|
||||
Searches the args with the given regexp and returns the matches. If no
|
||||
match is found, errmsg is given.
|
||||
|
||||
"public"
|
||||
Requires that the command be sent in a channel instead of a private
|
||||
message.
|
||||
|
||||
"private"
|
||||
Requires that the command be sent in a private message instead of a
|
||||
channel.
|
||||
|
||||
"otherUser"
|
||||
Returns the user specified by the username or hostmask in the argument.
|
||||
|
||||
"regexpMatcher"
|
||||
Gets a matching regexp argument (m// or //).
|
||||
|
||||
"validChannel"
|
||||
Gets a channel argument once it makes sure it's a valid channel.
|
||||
|
||||
"regexpReplacer"
|
||||
Gets a replacing regexp argument (s//).
|
||||
|
||||
"owner"
|
||||
Requires that the command caller has the "owner" capability.
|
||||
|
||||
"admin"
|
||||
Requires that the command caller has the "admin" capability.
|
||||
|
||||
"checkCapability", capability
|
||||
Checks to make sure that the caller has the specified capability.
|
||||
|
||||
"checkChannelCapability", capability
|
||||
Checks to make sure that the caller has the specified capability on the
|
||||
channel the command is called in.
|
||||
|
||||
Contexts List
|
||||
=============
|
||||
What contexts are available for me to use?
|
||||
|
||||
The list of available contexts is below. Unless specified otherwise, it can be
|
||||
assumed that the type returned by the context itself matches the type of the
|
||||
converter it is applied to.
|
||||
|
||||
any
|
||||
Looks for any number of arguments matching the supplied converter. Will
|
||||
return a sequence of converted arguments or None.
|
||||
|
||||
many
|
||||
Looks for multiple arguments matching the supplied converter. Expects at
|
||||
least one to work, otherwise it will fail. Will return the sequence of
|
||||
converted arguments.
|
||||
|
||||
optional
|
||||
Look for an argument that satisfies the supplied converter, but if it's not
|
||||
the type I'm expecting or there are no arguments for us to check, then use
|
||||
the default value. Will return the converted argument as is or None.
|
||||
|
||||
additional
|
||||
Look for an argument that satisfies the supplied converter, making sure
|
||||
that it's the right type. If there aren't any arguments to check, then use
|
||||
the default value. Will return the converted argument as is or None.
|
||||
|
||||
rest
|
||||
Treat the rest of the arguments as one big string, and then convert. If the
|
||||
conversion is unsuccessful, restores the arguments.
|
||||
|
||||
getopts
|
||||
Handles --option style arguments. Each option should be a key in a
|
||||
dictionary that maps to the name of the converter that is to be used on
|
||||
that argument. To make the option take no argument, use "" as the converter
|
||||
name in the dictionary. For no conversion, use None as the converter name
|
||||
in the dictionary.
|
||||
|
||||
first
|
||||
Tries each of the supplied converters in order and returns the result of
|
||||
the first successfully applied converter.
|
||||
|
||||
reverse
|
||||
Reverse the argument list, apply the converters, and then reverse the
|
||||
argument list back.
|
||||
|
||||
commalist
|
||||
Looks for a comma separated list of arguments that match the supplied
|
||||
converter. Returns a list of the successfully converted arguments. If any
|
||||
of the arguments fail, this whole context fails.
|
||||
|
||||
|
||||
Final Word
|
||||
==========
|
||||
|
||||
Now that you know how to use wrap, and you have a list of converters and
|
||||
contexts you can use, your task of writing clean, simple, and safe plugin code
|
||||
should become much easier. Enjoy!
|
||||
|
Loading…
x
Reference in New Issue
Block a user