Tanjun, but without decorators!

Tanjun uses a lot of decorators to add a lot of functionality, but did you know decorators are optional? You can make a full Tanjun bot without them! Let's take a look at how!

By KatieKat

Tanjun uses a lot of decorators! The decorators are wonderful in helping simplify and cut down how much code we end users have to write, but it can hide what's really happening. This post will walk you through making a slash command and slash command group with no decorators at all!

A simple Slash Command

Below we have the most simple Slash command we can make. All it does is respond with "Pong!" when called, but this time we use no decorators.

On line 3 we define our callback. Like with the regular decorator method, this callback needs to accept a SlashContext parameter as well as any extra parameters you define. For now, we are not using any extra parameters, so just a ctx parameter is used. There is no real change to this function outside of the lack of decorators.

To make our callback function work as an actual command we need create a tanjun.SlashCommand. On line 6 we instantiate this class and pass it 3 parameters; the function to act as a callback, a string to act as the commands name, and lastly a string to act as the commands description. Look familiar? This is all tanjun.as_slash_command does when used as a decorator on a function, just with a little added validation. Just like with as_slash_command you can also pass in other key-word options like always_defer or default_to_ephemeral.

Finally on line 12 we setup the wonderfully simple one-liner that both registers all commands/groups in the local scope to the new component and simultaneously generates a load/unload function for us. If you load the component into your Tanjun client now you should have a Slash Command without using a single decorator!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import tanjun

async def test_no_dec(ctx: tanjun.abc.SlashContext):
    await ctx.respond(f"Pong!")

slash_cmd = tanjun.SlashCommand(
    test_no_dec,
    "test-no-decorator",
    "This is a command without a decorator!",
)

component = tanjun.Component().load_from_scope().make_loader()

Adding Options!

One of our favorite features of Slash Commands are the options. Decorators like tanjun.with_str_slash_option allow members to provide client-side validated input. As you've seen in the previous example decorators are generally just a small wrapper that we can easily replicate. Options are no different! tanjun.SlashCommand offers methods that are exact mirrors of those with_x_as_slash_option. In fact, the with_x_as_slash_option calls this method on the SlashCommand is decorating! With this information, let's update that code.

On line 3 the callback has been changed to accept 3 parameters; the default SlashContext, a say string, and a repeat int. The callback body has been updated too. It now uses repeat to make a for loop, and responds with the say string.

Line 7's definition of the SlashCommand remains unchanged from the previous example.

Line 13-14 add the new options. By using slash_cmd.add_x_option(...) we are able to setup options using the same arguments and parameters as the with_x_as_slash_option decorators.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import tanjun

async def test_no_dec(ctx: tanjun.abc.SlashContext, say: str, repeat: int):
    for _ in range(repeat):
        await ctx.respond(f"{say}!")

slash_cmd = tanjun.SlashCommand(
    test_no_dec,
    "test-no-decorator",
    "This is a command without a decorator!",
)

slash_cmd.add_str_option("say", "What the bot will say.")
slash_cmd.add_int_option("repeat", "How many times the bot should say it.", default=1)

component = tanjun.Component().load_from_scope().make_loader()

Aside for Message Commands

While SlashCommands have a nice client-side validation via Discord, MessageCommands do not have this benefit. Instead Tanjun provides a ShlexParser to add Unix style options and arguments. Below we will look at a simple MessageCommand to query Spotify.

On line 7 we create a placeholder converter function.

Line 10 we define a placeholder callback to be run when the command is sent.

Line 13 - 16 creates the arguments for our MessageCommand. Notice that we decorate with the "sort" option/argument first, then the "query" option/argument. This will be important later. Even though we said that MessageCommands use ShlexParaser, we don't use it here at all. That's intentional actually! If no "parser" is set when you add your first with_option decorator, tanjun will automatically apply the builtin ShlexParser for you.

Line 17 we use @tanjun.as_message_command to decorate our callback.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import tanjun


_SPOTIFY_TYPES = ['track', 'artist', 'publisher']
_SORT_OPTS = ['a-z', 'rating', 'streamed']

def _assert_in_choices(choices: list):
    ...

async def spotify_callback(ctx: tanjun.abc.Context, query: str):
    ...

@tanjun.with_option("sort", "--sort", "-s", default="a-z", converters=_assert_in_choices(_SORT_OPTS))
@tanjun.with_argument("sort")
@tanjun.with_option("type", "--type", "-t", default="track", converters=_assert_in_choices(_SPOTIFY_TYPES))
@tanjun.with_argument("query")
@tanjun.as_message_command("spotify")
async def spotify_command(
    ctx: tanjun.abc.Context,
    query: str,
    sort: str,
    **kwargs: str,
) -> None:
    ...

Now let's see what kind of changes you can expect for MessageCommand.

Line 13 begins by instantiating a new MessageCommand. We provide the callback with the command name to bind to.

On line 15 we instantiate the tanjun.ShlexParser() previously mentioned.

Lines 16 - 17 adds our arguments and options to the ShlexParser. Like we established before, most decorators have direct method/functions parallels, and that holds true here. We use the ShlexParser().add_argument(...).add_option(...) instead of with_argument and with_option.

Important Note:

Notice that the arguments/options are added in the reverse order in this example!!

With decorators, arguments are added sort first and query second. ShlexParser adds query first and sort second. This is because the ShlexParser interprets top-down. This means the first command we see runs first. All decorators execute in reverse order or bottom-up. This means the bottom most decorator runs first, then that result passes to the decorator above it.

Finally line 19 adds the ShlexParser to the spotify_command. Without this call the argument/options remain totally separate from the command itself.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import tanjun


_SPOTIFY_TYPES = ['track', 'artist', 'publisher']
_SORT_OPTS = ['a-z', 'rating', 'streamed']

async def spotify_callback(ctx: tanjun.abc.Context, query: str, sort: str):
    ...

def _assert_in_choices(choices: list):
    ...

spotify_command = tanjun.MessageCommand(spotify_callback, "spotify")

shlex_parser = tanjun.ShlexParser()
shlex_parser.add_argument("query").add_option("type", "--type", "-t", default="track", converters=_assert_in_choices(_SPOTIFY_TYPES))
shlex_parser.add_argument("sort").add_option("sort", "--sort", "-s", default="a-z", converters=_assert_in_choices(_SORT_OPTS))

spotify_command.set_parser(shlex_parser)

Checks & Hooks

You might be starting to see a trend here. Most decorators just call methods on the object they are decorating. Hooks and Checks are no exception to this!

Below we've added some placeholder functions on lines 4 and 7. You can just replace this with your own valid hook or check, or one that Tanjun provides.

The definitions from lines 11-18 remain the same as the first example, though it will work with options too!

Line 20 calls tanjun.SlashCommand.add_check() to add your check callback to the command. This method is what tanjun.with_check() calls under the hood.

Finally line 22 calls tanjun.SlashCommand.set_hooks(). In this method we create a new tanjun.AnyHooks() object and then call AnyHooks().set_on_error().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import tanjun


async def custom_check(...):
    ...

async def custom_hook(...):
    ...


async def test_no_dec(ctx: tanjun.abc.SlashContext):
    await ctx.respond(f"Ping!")

slash_cmd = tanjun.SlashCommand(
    test_no_dec,
    "test-no-decorator",
    "This is a command without a decorator!",
)

slash_cmd.add_check(custom_check)

slash_cmd.set_hooks(tanjun.AnyHooks().set_on_error(custom_hook))

Note: If you're not sure how to use or write Hooks and Checks stay tuned! Those will be coming up soon!

Adding groups

Lastly you can also add Command Groups without decorators.

Lines 1-14 remain the same as the first example. A simple callback and creating a SlashCommand.

Line 17 we create a new tanjun.SlashCommandGroup() directly, instead of using the decorator @tanjun.slash_command_group(). The class accepts all the same parameters as the decorator does, with the first two parameters being name and description.

On line 18 we use SlashCommandGroup().add_commad(), which makes our slash_cmd part of this command group.

If you load this plugin, your command should now start appearing as /tester test-no-dec!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import tanjun

async def test_no_dec(ctx: tanjun.abc.SlashContext, say: str, repeat: int):
    for _ in range(repeat):
        await ctx.respond(f"{say}!")

slash_cmd = tanjun.SlashCommand(
    test_no_dec,
    "test-no-decorator",
    "This is a command without a decorator!",
)

slash_cmd.add_str_option("say", "What the bot will say.")
slash_cmd.add_int_option("repeat", "How many times the bot should say it.", default=1)


cmd_grp = tanjun.SlashCommandGroup("tester", "Just a test group")
cmd_grp.add_command(slash_cmd)

component = tanjun.Component().load_from_scope().make_loader()

Chaining

While this article was primarily trying to give examples that are "simple" and demystify what exactly the decorators are doing, there is a better way of writing decorator-less code! Tanjun aims to be more "functional" than most Python libraries and how well it chains code is an example of that.

Let's consider the example from Adding Groups. Instead of creating a slash_cmd variable, then separately calling slash_cmd.add_x_option, then separately adding another option, we can do this all in one call!

Notice line 7 slash_cmd = (; this means execute everything within the parens and assign the value to slash_cmd. Tanjun is written in a way that add_x_option(...) always returns the SlashCommand being added to. This design choice allows us to chain all options into one expression. We can also do this with load_from_scope() and make_loaders(), which always returns the Component() the methods were called from.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import tanjun

async def test_no_dec(ctx: tanjun.abc.SlashContext, say: str, repeat: int):
    for _ in range(repeat):
        await ctx.respond(f"{say}!")

slash_cmd = (
    tanjun.SlashCommand(
        test_no_dec,
        "test-no-decorator",
        "This is a command without a decorator!",
    )
    .add_str_option("say", "What the bot will say.")
    .add_int_option("repeat", "How many times the bot should say it.", default=1)
)

cmd_grp = tanjun.SlashCommandGroup("tester", "Just a test group").add_command(slash_cmd)

component = tanjun.Component().load_from_scope().make_loader()

We appreciate Donations & Tips!

We would much rather be making cool tutorials, streaming code, and making accessibility tools. Unfortunately we have bills and a job. Hopefully one day, with enough Patrons and donations this can be fulltime!

Authors

Katie is the Social Protector for the Patchwork Collective and the first Part to start communicating with Aster in early 2021. Most of Katie's System Role revolves around handling social situations, work, and dealing with external strangers. Once the internal walls came down Katie quickly found her interests in research, programming, and writing.

>> Learn More about Katie and The Patchwork Collective