plugin_tutorial: rewrite / condense plugin.py section

- Add additional inline links to related doc pages
- Mention additional common uses of irc.reply and irc.error
This commit is contained in:
James Lu
2023-05-03 21:29:32 -07:00
committed by James Lu
parent fa43baf9a8
commit 54f94be56d
4 changed files with 170 additions and 191 deletions

View File

@ -28,6 +28,8 @@ When a plugin is unloaded (or is to be reloaded), the ``die``
method is called (with no parameter). method is called (with no parameter).
Also make sure you always call the parent's ``die``. Also make sure you always call the parent's ``die``.
.. _do-method-handlers:
Commands and numerics Commands and numerics
===================== =====================

View File

@ -130,40 +130,30 @@ in the :ref:`Advanced Plugin Config Tutorial <configuration-tutorial>`.
plugin.py plugin.py
========= =========
Here's the moment you've been waiting for, the overview of plugin.py and how to ``plugin.py`` includes the core code for the plugin. For most plugins this will
make our plugin actually do stuff. include command handlers, as well as anything else that's relevant to its
particular use case (database queries,
:ref:`HTTP server endpoints <http_plugins>`,
:ref:`IRC command triggers <do-method-handlers>`, etc.)
At the top, same as always, is the standard copyright block to be used and As with any Python module, you'll need to import any dependencies you want,
abused at your leisure. in addition to the standard ``supybot`` imports included in the plugin
template::
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. Since
we'll be making use of Python's standard 'random' module, you'll need to add
the following line to the list of imports::
import random import random
Now, the plugin class itself. What you're given is a skeleton: a simple The bulk of the plugin definition then resides in a subclass of
subclass of :class:`callbacks.Plugin` for you to start with. The only real content it :class:`callbacks.Plugin`. By convention, the class name is equal to the name of
has is the boilerplate docstring, which you should modify to reflect what the the plugin, though this is not strictly required (the actual linkage is done by
boilerplate text says - it should be useful so that when someone uses the the ``Class = Random`` statement at the end of the file). It is helpful to fill
plugin help command to determine how to use this plugin, they'll know what they in the plugin docstring with some more details on how to actually use the plugin
need to do. Ours will read something like:: too: this info can be shown on a live bot using the
``plugin help <plugin name>`` command.
"""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 class Random(callbacks.Plugin):
plugin do something. First of all, to get any random numbers we're going to """This plugin contains commands relating to random numbers, including random sampling from a list and a simple dice roller."""
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): def __init__(self, irc):
# Make sure to call the superclass' constructor when you define a custom one # Make sure to call the superclass' constructor when you define a custom one
@ -171,85 +161,71 @@ Here's what our __init__ looks like::
self.rng = random.Random() # create our rng self.rng = random.Random() # create our rng
self.rng.seed() # automatically seeds with current time self.rng.seed() # automatically seeds with current time
Make sure you add it with one indentation level more than the ``class`` line For this sample plugin, we define a custom constructor (``__init__``) that
(ie. with four spaces before the ``def``). instantiates a random number generator instance and pre-seeds it. This isn't
technically necessary for Python's ``random`` module, but this helps outline
how to write a similar constructor. Notice in particular how you must pass in
the ``irc`` argument in addition to ``self``.
Now, the first two lines may look a little daunting, but it's just .. note::
administrative stuff required if you want to use a custom ``__init__``. If we TODO(jlu): semantically, what does ``irc`` refer to? Most plugins don't
didn't want to do so, we wouldn't have to, but it's not uncommon so I decided actually reference it on load time.
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 Basic command handler
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::
Our first command definition can immediately follow::
# dummy comment to indent the below code consistently
@wrap
def random(self, irc, msg, args): def random(self, irc, msg, args):
"""takes no arguments """takes no arguments
Returns the next random number from the random number generator. Returns the next random number from the random number generator.
""" """
irc.reply(str(self.rng.random())) irc.reply(str(self.rng.random()))
random = wrap(random)
Same as before, you have to past it with one indentation level. .. note::
And that's it. Now here are the important points. All functions used as commands must have an all lowercase name.
First and foremost, all plugin commands must have all-lowercase function A command function taking in no arguments from IRC will still require 4
names. If they aren't all lowercase they won't show up in a plugin's list of arguments; they are as follows:
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 - ``self``: refers to the class instance. It is common to keep local state
is what the argument list for all methods that are to be used as commands must for the plugin as instance variables within the plugin class.
start with. If you wanted additional arguments, you'd append them onto the end, - ``irc``: refers to the IRC network instance the command was called on
but since we take no arguments we just stop there. I'll explain this in more - ``msg``: a :ref:`supybot.ircmsgs <supybot-ircmsgs>` instance; refers to the
detail with our next command, but it is very important that all plugin commands IRC message that triggered this command.
are class methods that start with those four arguments exactly as named. - ``args``: a raw list of remaining unconverted arguments; new plugins that
use :ref:`@wrap <using-wrap>` for automatic argument type conversion should
never need to interact with ``args`` directly.
Next, in the docstring there are two major components. First, the very first The function docstring is expected to be in a particular format. First, the very
line dictates the argument list to be displayed when someone calls the help first line dictates the argument list to be displayed when someone calls the
command for this command (i.e., help random). Then you leave a blank line and ``help`` command on this command (i.e., ``help random``). Then, leave a blank
start the actual help string for the function. Don't worry about the fact that line and start the actual help string for the function. Don't worry about the
it's tabbed in or anything like that, as the help command normalizes it to fact that it's tabbed in or anything like that, as the help command normalizes
make it look nice. This part should be fairly brief but sufficient to explain it to make it look nice. This part should be fairly brief but sufficient to
the function and what (if any) arguments it requires. Remember that this should explain the function and what (if any) arguments it requires. Remember that this
fit in one IRC message which is typically around a 450 character limit. 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 The :py:meth:`irc.reply <supybot.callbacks.NestedCommandsIrcProxy.reply>` call
line: ``irc.reply(str(self.rng.random()))``. is a bit of magic: it issues a reply the same place as the message that
The :py:meth:`irc.reply <supybot.callbacks.NestedCommandsIrcProxy.reply>` triggered the command. i.e. this may be in a channel or in a private
function issues a reply conversation with the bot.
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 Lastly, notice that commands go through the :ref:`@wrap <using-wrap>`
handle argument parsing for plugin commands in a very nice and very powerful decorator for automatic argument type conversion. For commands that require no
way. With no arguments, we simply need to just wrap it. For more in-depth parameters, calling ``@wrap`` with no arguments is enough.
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 Command handler with parameters
precisely what it is. However, we developed this before decorators existed and -------------------------------
haven't changed the syntax due to our earlier requirement to stay compatible
with Python 2.3. As we now require Python 2.4 or greater, this may eventually
change to support work via decorators.)
Now let's create a command with some arguments and see how we use those in our 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 plugin commands. This ``seed`` command lets the user pick a specific RNG 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::
# dummy comment to indent the below code consistently
@wrap(['float'])
def seed(self, irc, msg, args, seed): def seed(self, irc, msg, args, seed):
"""<seed> """<seed>
@ -258,84 +234,85 @@ it can be any hashable object). Here's what this command looks like::
""" """
self.rng.seed(seed) self.rng.seed(seed)
irc.replySuccess() irc.replySuccess()
seed = wrap(seed, ['float'])
You'll notice first that argument list now includes an extra argument, seed. If For functions that use ``@wrap`` (described further in the
you read the wrap tutorial mentioned above, you should understand how this arg :ref:`Using commands.wrap tutorial <using-wrap>`), additional command arguments
list gets populated with values. Thanks to wrap we don't have to worry about are handled by:
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 1. Adding :ref:`type converters <wrap-converter-list>`, one for each parameter,
on the first line. Arguments go in <> and optional arguments should be to the list passed into ``@wrap``
surrounded by ``[]`` (we'll demonstrate this later as well). 2. Adding one function parameter per argument to the command function
definition. (i.e. ``def seed(self, irc, msg, args, seed)`` instead of
``def seed(self, irc, msg, args)``)
The body of the function should be fairly straightforward to figure out, but it We also modify the docstring to document this function. Note the syntax
introduces a new function - on the first line: by convention, required arguments go in ``<>`` and optional
arguments should be surrounded by ``[]``.
The function body includes a new method
:py:meth:`irc.replySuccess <supybot.callbacks.RichReplyMethods.replySuccess>`. :py:meth:`irc.replySuccess <supybot.callbacks.RichReplyMethods.replySuccess>`.
This is just a generic "I This is just a generic "I succeeded" command which responds with whatever the
succeeded" command which responds with whatever the bot owner has configured to bot owner has configured in ``config supybot.replies.success``.
be the success response (configured in supybot.replies.success). Note that we Also, by using ``@wrap``, we don't need to do any type checking inside the
don't do any error-checking in the plugin, and that's because we simply don't function itself - this is handled separately, and invalid argument values will
have to. We are guaranteed that seed will be a float and so the call to our cause the command to error before it reaches the wrapped function.
RNG's seed is guaranteed to work.
Lastly, of course, the wrap call. Again, read the wrap tutorial for fuller With this alone you'd be able to make a range of useful plugin commands, but
coverage of its use, but the basic premise is that the second argument to wrap we'll go include some more examples to illustrate common patterns.
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 Command handler with list-type arguments
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 The next sample command is named ``sample`` (no pun intended): it takes a random
from a list provided by the user:: sample of arbitrary size from a list provided by the user::
# dummy comment to indent the below code consistently
def sample(self, irc, msg, args, n, items): def sample(self, irc, msg, args, n, items):
"""<number of items> <item1> [<item2> ...] """<number of items> <item1> [<item2> ...]
Returns a sample of the <number of items> taken from the remaining Returns a sample of the <number of items> taken from the remaining
arguments. Obviously <number of items> must be less than the number arguments. <number of items> must be less than the number
of arguments given. of arguments given.
""" """
if n > len(items): if n > len(items):
# Calling irc.error with Raise=True is an alternative early return
irc.error('<number of items> must be less than the number ' irc.error('<number of items> must be less than the number '
'of arguments.') 'of arguments.', Raise=True)
return
sample = self.rng.sample(items, n) sample = self.rng.sample(items, n)
sample.sort() sample.sort()
irc.reply(utils.str.commaAndify(sample)) irc.reply(utils.str.commaAndify(sample))
sample = wrap(sample, ['int', many('anything')]) sample = wrap(sample, ['int', many('anything')])
This plugin command introduces a few new things, but the general structure The important thing to note is that list type arguments are rolled into one
should look fairly familiar by now. You may wonder why we only have two extra parameter in the command function by the ``many`` filter. Similar "multiplicity"
arguments when obviously this plugin can accept any number of arguments. Well, handlers are documented :ref:`here <wrap-multiplicity-handlers>`.
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 We also update the docstring to use the ``[]`` convention when surrounding
optional items after the first item. optional arguments.
The body of the plugin should be relatively easy to read. First we check and For this function's body,
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.
:py:meth:`irc.error <supybot.callbacks.NestedCommandsIrcProxy.error>` :py:meth:`irc.error <supybot.callbacks.NestedCommandsIrcProxy.error>`
is kind of like irc.replySuccess only it is like
gives an error message using the configured error format (in :py:meth:`irc.replySuccess <supybot.callbacks.NestedCommandsIrcProxy.replySuccess>`
``supybot.replies.error``). Otherwise, we use the sample function from our RNG to but for error messages. We prefer using this instead of ``irc.reply`` for error
get a sample, then we sort it, and we reply with the 'utils.str.commaAndify'ed signaling because its behaviour can be configured specially. For example, you
version. The utils.str.commaAndify function basically takes a list of strings can force all errors to go in private by setting the ``reply.error.inPrivate``
option, and this can help reduce noise on a busy channel.
Also, ``irc.error()`` with no text will return a generic error message
configured in ``supybot.replies.error``, but this is not a valid call to
:py:meth:`irc.reply <supybot.callbacks.NestedCommandsIrcProxy.reply>`.
``utils.str.commaAndify`` is a simple helper that takes a list of strings
and turns it into "item1, item2, item3, item4, and item5" for an arbitrary 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 length. Limnoria has accumulated many such helpers in its lifetime, many of
tutorial. which are described in the :ref:`Using Utils <using-utils>` page.
Now for the last command that we will add to our plugin.py. This last command Command handler with optional arguments
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:: Now for the last command that we will add to our plugin.py. This ``diceroll``
command will allow the bot users to roll an arbitrary n-sided die, with n
defaulting to 6::
# dummy comment to indent the below code consistently
def diceroll(self, irc, msg, args, n): def diceroll(self, irc, msg, args, n):
"""[<number of sides>] """[<number of sides>]
@ -346,20 +323,12 @@ as they so choose. Here's the code for this command::
irc.reply(s, action=True) irc.reply(s, action=True)
diceroll = wrap(diceroll, [additional(('int', 'number of sides'), 6)]) diceroll = wrap(diceroll, [additional(('int', 'number of sides'), 6)])
The only new thing learned here really is that the irc.reply method accepts an The only new thing described here is that ``irc.reply(..., action=True)`` makes
optional argument action, which if set to True makes the reply an action the bot perform a `/me`. There are some other flags described in the
instead. So instead of just crudely responding with the number, instead you :py:meth:`irc.reply <supybot.callbacks.NestedCommandsIrcProxy.reply>`
should see something like * supybot rolls a 5. You'll also note that it uses a documentation too: common ones include ``private=True``, which
more advanced wrap line than we have used to this point, but to learn more forces a private message, and ``notice=True``, which forces the reply to use
about wrap, you should refer to the wrap tutorial NOTICE instead of PRIVMSG.
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 test.py
======= =======

View File

@ -1,3 +1,5 @@
.. _using-utils:
**************************** ****************************
Using Supybot's utils module Using Supybot's utils module
**************************** ****************************

View File

@ -1,3 +1,5 @@
.. _using-wrap:
***************************************************** *****************************************************
Using commands.wrap to parse your command's arguments Using commands.wrap to parse your command's arguments
***************************************************** *****************************************************
@ -154,6 +156,8 @@ thing that just "something" would return, but rather a list of "something"s.
Converter List Converter List
============== ==============
.. _wrap-converter-list:
Below is a list of all the available converters to use with wrap. If the 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 converter accepts any arguments, they are listed after it and if they are
optional, the default value is shown. optional, the default value is shown.
@ -468,6 +472,8 @@ first
Tries each of the supplied converters in order and returns the result of Tries each of the supplied converters in order and returns the result of
the first successfully applied converter. the first successfully applied converter.
.. _wrap-multiplicity-handlers:
Multiplicity Multiplicity
------------ ------------