Rewrite the commands.wrap guide

- Add concrete examples of contexts: optional and multiple arguments
- Add examples of converters that don't (always) read command line arguments, such as 'op' and 'channel'
- Switch examples to use the decorator syntax for "wrap". This is common among chatbots these days
- Make the motivating example more concise
This commit is contained in:
James Lu
2025-02-04 20:16:33 -08:00
committed by Val Lorentz
parent 11b4510424
commit 676ee3a58d

View File

@ -4,162 +4,170 @@
Using commands.wrap to parse your command's arguments
*****************************************************
The :func:`supybot.commands.wrap` decorator is used to register functions as
plugin commands. It abstracts away the repetitive parts of command
parsing, as well as common checks for permissions and semantics
(e.g. for commands that must be associated with a channel).
If you kept the ``from supybot.commands import *`` import from the
plugin template, ``wrap`` should already be available in the global namespace.
.. contents::
Introduction
============
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
Motivation
==========
First off, to get the wrap function, it is recommended (strongly) that you use
the following import line::
from supybot.commands import *
In the old days, command parsing was done manually by every plugin function
and looked something like this::
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-plugin-create script.
def repeat(self, irc, msg, args):
"""<num> <text>
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::
Repeats <text> <num> times.
"""
(num, text) = privmsg.getArgs(args, required=2)
try:
num = int(num)
except ValueError:
raise callbacks.ArgumentError
irc.reply(num * text)
def repeat(self, irc, msg, args):
"""<num> <text>
For a simple command, the argument parsing and validation accounted for the
majority of the function contents. Compare this with the much shorter wrapped
version::
Repeats <text> <num> times.
"""
(num, text) = privmsg.getArgs(args, required=2)
try:
num = int(num)
except ValueError:
raise callbacks.ArgumentError
irc.reply(num * text)
@wrap(['int', 'text'])
def repeat(self, irc, msg, args, num, text):
"""<num> <text>
Note that all of the argument validation and parsing takes up 5 of the 6 lines
(and you should have seen it before we had privmsg.getArgs!). Now, here's what
our command will look like with wrap applied::
Repeats <text> <num> times.
"""
irc.reply(text * num)
def repeat(self, irc, msg, args, num, text):
"""<num> <text>
The goal of ``wrap`` is to abstract all these checks into a common place, so
that individual commands don't need as much boilerplate.
Repeats <text> <num> times.
"""
irc.reply(text * num)
repeat = wrap(repeat, ['int', 'text'])
Syntax Description
==================
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.
:func:`supybot.commands.wrap` is a decorator, and it takes in a list of
**converters**. Each converter is responsible for parsing a command argument or
performing some check; there is a
:ref:`list of converters <wrap-converter-list>` below.
So, now that you see the benefits of wrap, let's figure out what stuff we have
to do to use it.
The function being wrapped takes in one additional argument for
each converter's output variable. At runtime, the *converted* (parsed)
variables will be passed as ``arg1``, ``arg2``, etc. in order::
Syntax Changes
==============
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, ...])
@wrap(['converter1', 'converter2', ...])
def commandname(self, irc, msg, args, <arg1>, <arg2>, ...):
# ...
.. note::
In older code you may see definitions in the form::
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).
commandname = wrap(commandname, ['converter1', 'converter2', ...])
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.
These are equivalent to the decorator syntax above.
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::
Note that converters are specified by name as *strings*, to avoid conflicts
with built-in functions.
commandname = wrap(commandname, [(converterWithArgs, arg1, arg2),
converterWithoutArgs1, converterWithoutArgs2])
Some converters also take parameters. In these cases, they will be passed in as
a tuple containing the converter name as the first argument::
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.
@wrap([('converterWithArgs', 123, 456), 'converterWithoutArgs'])
def commandname(self, irc, msg, args, arg1, arg2):
# ...
Customizing Wrap
================
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
do the actual parsing and validation.
Contexts: Optional Parameters and Multiple Values
=================================================
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 anything 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.
Whereas converters specify how to parse an individual argument, **converter
contexts** control the multiplicity and placement of a variable. This is akin
to ``nargs`` in :py:class:`argparse.ArgumentParser`, and allows defining
things like optional 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.
An example, making the number of repetitions **optional** in the ``repeat`` command::
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; 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, with the converter (and its
arguments) as arguments. It's quite simple. Here's an example::
@wrap([optional('int', 2), 'text'])
def repeat(self, irc, msg, args, num, text):
"""[<num>] <text>
commandname = wrap(commandname, [optional('int'), many('something')])
Repeats <text> <num> times. <num> defaults to 2 if not specified.
"""
irc.reply(text * num)
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).
In this example, the command looks for an optional integer argument before the
text. If the num is not provided, e.g. by passing text that doesn't start with
a number, it will default to 2. The default value itself is also optional; it
falls back to ``None`` if not provided.
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.
Another example, using the ``many`` context to parse
:ref:`at least one <wrap-multiplicity-handlers>` parameter::
@wrap([many('float')])
def average(self, irc, msg, args, nums):
"""<number 1> [<number 2> <number 3> ...]
Returns the average of the numbers given.
"""
average = sum(nums) / len(nums)
irc.reply(average)
In this case, ``nums`` will be a *list* of numbers.
A :ref:`list of contexts <wrap-context-list>` is provided in this page.
.. _wrap-converter-list:
Using Converters to Check State
===============================
Some converters check the bot's state or output a variable from state.
For example, here is the definition of the ``seen`` command::
@wrap(['channel', 'something'])
def seen(self, irc, msg, args, channel, name):
"""[<channel>] <nick>
Returns the last time <nick> was seen and what <nick> was last seen
saying. <channel> is only necessary if the message isn't sent on the
channel itself. <nick> may contain * as a wildcard.
"""
# ...
If a channel is not specified but the command was run inside a channel, the
**channel** converter automatically fills that parameter with the current channel.
When running from a direct message, a channel *must* be specified or the command
will fail.
A more complex example is the ``kick`` command, which includes a couple of
state checks::
@wrap(['op', ('haveHalfop+', _('kick someone')), commalist('nickInChannel'), additional('text')])
def kick(self, irc, msg, args, channel, nicks, reason):
"""[<channel>] <nick>[, <nick>, ...] [<reason>]
Kicks <nick>(s) from <channel> for <reason>. If <reason> isn't given,
uses the nick of the person making the command as the reason.
<channel> is only necessary if the message isn't sent in the channel
itself.
"""
# ...
- The **op** converter checks that the caller has the op
:ref:`capability <capabilities>` (permission) in the bot.
- The **haveHalfop+** converter checks that the bot itself has halfop or above
in the channel, as otherwise it can't kick anyone.
- **commalist('nickInChannel')** verifies that each nick passed in the list corresponds to
someone currently in the channel.
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
optional, the default value is shown.
@ -397,10 +405,11 @@ text
from the "anything" context in that it clobbers the arg string when it's
done. Using any converters after this is most likely incorrect.
.. _wrap-context-list:
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.
@ -463,7 +472,6 @@ reverse
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!
Now that you know how to use ``wrap``, writing clean and safe plugins should become
much easier. Enjoy!