Deeper Look at Dependency Injection in Tanjun

Configuring Tanjun

By Asterisk KatieKat

Tanjun uses a more functional approach than other Discord libraries. Functional in this post does not mean, provides more utility, but rather is written in a functional programming style. One good example is how hikari and Tanjun implement a Bot.

In other Discord libraries you create a new class that inherits from a base Bot class the library provides. After you inherit from that base class you can then add any new methods, attributes or whatever else you need. Common practice in other libraries is to then define your own __init__ method where you setup dependencies that your Bot needs. Common examples would be connections to Redis, Postgres, or any external service. Once those connections are validated and working you could attach them to your bot via self.redis = aioredis.Redis(...), or whatever your situation needs. From that point on all your plugins can access self.redis for your Redis client!

If you followed along with the last two parts of this series, you might start to see the issue. If Tanjun doesn't want you to inherit from it's classes, like Client, to add attributes or methods, how do you access those needed dependencies? Tanjun provides a wonderful built in solution for those cases via tanjun.injected, called Dependency Injection.

How does Injection Work?

Dependency Injection allows you to register specific objects or functions and then access those registered objects in any async Tanjun callback. The only real limit to DI is that last part, "in any async Tanjun Callback." That means DI normally applies to command handlers, listeners, DI callbacks only. There are few exceptions to this for other builtin tanjun elements, like tanjun.conversion, where non-async code can be injected.

Injecting into a command or listener is simple to do. Just setup your component/command as you normally would then add an extra parameter. In the example below we create new command my_cmd_func and inject hikari.GatewayBot into that command. In python we are able to define "default parameters" on a function. This basically says "if no value is provided, use this" and that allows the injecting magic to happen. We can define that inj_bot should default to tanjun.inject(type=...)1, where ... is whatever object you registered. Tanjun will then do all the work to make inj_bot resolve to that object!

1
2
3
4
5
6
7
8
import tanjun

component = tanjun.Component()

@component.with_slash_command()
@tanjun.as_slash_command("my-cmd", "A command")
async def my_cmd_func(ctx: tanjun.SlashContext, inj_bot: hikari.GatewayBot = tanjun.inject(type=hikari.GatewayBot):
    print(inj_bot)

Note: To inject your dependencies you can use tanjun.inject, or tanjun.injected2 which is an alias of the previous.

What can be Injected?

Anything really! As mentioned earlier you can register your own objects, functions, instiate other libs like Redis or Postgres and register them. There's also a few ways to inject without registering first!

The first is to use the defaults tanjun preregisters for you. Once you start your tanjun.Client, tanjun automatically registers these objects via type3:

  • tanjun.Client
  • hikari.GatewayBot
  • hikari.api.RESTClient
  • hikari.api.Cache
  • hikari.api.EventManager
  • hikari.api.InteractionServer
  • hikari.api.ShardAware
  • hikari.api.VoiceComponent

The second is to inject local function. Below we reuse the slash command from earlier, but instead of injecting the hikari.GatewayBot directly by it's type we pass in a callback function my_injector. my_injector is just a regular async python function, but because we are using it as a callback that tanjun executes (via tanjun.inject) we can actually use DI on this callback!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@component.with_slash_command()
@tanjun.as_slash_command("my-cmd", "A command")
async def my_cmd_func(ctx: tanjun.SlashContext, bot: hikari.GatewayBot = tanjun.inject(callback=my_injector):
    print(inj_bot) # Prints a `hikari.GatewayBot object`



async def my_injector(inj_bot: hikari.GatewayBot = tanjun.inject(type=hikari.GatewayBot):
    print(inj_bot)
    return inj_bot

Caching

You can also cache the result of DI's if they are resource intensive! This is most commonly seen on networking tasks like fetching webpages or getting a bot prefix from a database. It's also very simple to setup! Just change tanjun.inject to tanjun.cached_inject4 and provide a expire_after parameter to manage the cache's time to live. Tanjun will then run the injection normally, but cache it's result for expire_after and reuse that result instead of re-running the code!

The last option is to register your own types into Tanjun. So let's look at how to do that!

How to Set Dependencies

If you have a dependency that requires some kind of configuration before it can be used, it's probably worth registering your own dependency. Luckily, tanjun provides tanjun.Client.set_type_dependency5 for this case. Below is a simple example to setup a Redis Client and register it. We start by setting up our bot and client. Then we configure an aioredis.Redis instance. Finally we register that new instance to the aioredis.Redis type with tanjun.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import hikari
import tanjun
import aioredis

bot = hikari.GatewayBot(...)
client = tanjun.Client.from_gateway_bot(bot)

redis_client = aioredis.Redis(...)

client.set_type_dependency(aioredis.Redis, redis_client)

Now if we were to set tanjun.inject(type=aioredis.Redis) on any tanjun callbacks, we would be passed this instance that we configured.

It is possible to use set_type_dependency with functions or even lambda's with set_type_dependency too. Just like with the previous example you can set the return of these callbacks to any type you want, even from other libraries like aioredis.Redis. Functions do not have to be registered though! Like the example in What Can Be Injected showed, tanjun.inject will also search the local scope for the referenced callback too.

Injecting with Multiple Types

So far we have shown how to inject based on a single type, but tanjun allows you to pass in multiple types too. Tanjun will then check those types in order as well as the literal Union if no other types resolve. In the example below tanjun will try to find a MyBotProxy type first, then hikari.GatewayBot, then finally Union (| is just shorthand for Union). Assuming the following two function snippets were decorated properly for tanjun as a Slash command, they would act identical.

1
2
async def my_cmd_func(ctx: tanjun.SlashContext, bot = tanjun.inject(type=MyBotProxy | hikari.GatewayBot):
async def my_cmd_func(ctx: tanjun.SlashContext, bot = tanjun.inject(type=typing.Union[MyBotProxy, hikari.GatewayBot]):

Defaults & Optional for Injection

If you are unsure that a dependency is registered it is also possible to provide a default. Assuming again that the following functions were decorated properly, they would function identically:

1
2
3
async def my_cmd_func(ctx: tanjun.SlashContext, bot = tanjun.inject(type=MyBotProxy | None):
async def my_cmd_func(ctx: tanjun.SlashContext, bot = tanjun.inject(type=typing.Union[MyBotProxy, None]):
async def my_cmd_func(ctx: tanjun.SlashContext, bot = tanjun.inject(type=typing.Optional[MyBotProxy]):

In all of the above cases tanjun will attempt to find MyBotProxy, and if it cannot will return None without any exceptions.

Starting Injected Dependencies that require the Bot Started

Sometimes you will have dependencies that require async methods to start or require your bot already be started up and running. We can handle these kinds of situations by registering callbacks67! Let's look at some code and see how we could set this up:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from protocols import Databases # Assuming this is a custom DB with a async open function

import hikari
import tanjun
import aioredis

bot = hikari.GatewayBot(...)
client = tanjun.Client.from_gateway_bot(bot)

db = Database(...)  # Add db config

client.set_type_dependency(type=Database, db)

client.add_client_callback(tanjun.ClientCallbackNames.STARTING, db.open)
client.add_client_callback(tanjun.ClientCallbackNames.CLOSING, db.close)

This will setup your dependency same as with the Redis example, but we also add the two final lines. add_client_callback lets us tell tanjun "run this callback when the bot starts/closes". Tanjun provides hooks for a number of useful events, and STARTING/CLOSING can be used to ensure dependencies with async startup's can be automatically started.

Next Time!

Now with a better understanding of what DI is and how it works in Tanjun we can start working on more concrete examples! Next post we will cover setting up a Reaction Role system with slash commands. The end result will be a plugin that allows you to add a Emote/Emoji and Role combination that the Bot will store in Postgres and Redis. When member sends the Role.name or Emoji/Emote in a specific channel, the Bot will toggle the role on the member. This will require setting up custom injectors for aiopg and aioredis!


Resources & Link's


  1. hikari-tanjun Documentation, Faster Speeding tanjun.inject, https://tanjun.cursed.solutions/master/tanjun.html#inject 

  2. hikari-tanjun Documentation, Faster Speeding tanjun.injecting.injected, https://tanjun.cursed.solutions/master/tanjun/injecting.html#injected 

  3. hikari-tanjun Source Code tanjun.client, Faster Speeding, https://github.com/FasterSpeeding/Tanjun/blob/master/tanjun/clients.py#L615 

  4. hikari-tanjun Documentation, Faster Speeding, https://tanjun.cursed.solutions/master/tanjun.html#cached_inject 

  5. hikari-tanjun Documentation tanjun.injecting.InjectorClient.set_type_dependency, Faster Speeding, https://tanjun.cursed.solutions/master/tanjun/injecting.html#InjectorClient.set_type_dependency 

  6. hikari-tanjun Documentation tanjun.Client.add_client_callback, Faster Speeding, https://tanjun.cursed.solutions/master/tanjun.html#Client.add_client_callback 

  7. hikari-tanjun Documentation tanjun.ClientCallbackNames, Faster Speeding, https://tanjun.cursed.solutions/master/tanjun/clients.html#ClientCallbackNames 

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

Asterisk

Role: Host Apparently Normal Part

Asters's current pfp

Pre-2020 Aster consider themself a very neurotypical male. As the COVID pandemic hit it magnified many issues Aster had been able to hide or compensate for. Since diagnosis Aster now tries to use their previous skills with teaching and speaking to help spread education and awareness about DID/OSDD. In their spare time Aster enjoys programming, teaching, and helping build the Official Game Theory Discord.

>> Learn More about Aster and The Patchwork Collective

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