Skip to content

Instantly share code, notes, and snippets.

@Rapptz
Last active November 3, 2023 15:40
Show Gist options
  • Star 39 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save Rapptz/6706e1c8f23ac27c98cee4dd985c8120 to your computer and use it in GitHub Desktop.
Save Rapptz/6706e1c8f23ac27c98cee4dd985c8120 to your computer and use it in GitHub Desktop.

Changes to async initialisation in discord.py

There have been a few breaking changes involving the way Client deals with the asyncio event loop.

Background

Originally, you could pass a loop object and retrieve it using the Client.loop attribute. This design made sense for the older 3.4-3.7 Python. Likewise, discord.py had a method named Client.run to abstract all the worries of clean up for you.

At around Python 3.7, a new function named asyncio.run was added to the standard library to help with clean up. Unfortunately, this function is still buggy on Windows and leads to some rather noisy clean up code. However, from a maintenance perspective it was desirable to switch to asyncio.run instead of Client.run.

The issue with supporting asyncio.run is that it creates a new event loop and maintains ownership of it. The problem with creating a new event loop is that if two objects have different event loops then things error out. Compounded by this, in Python 3.8, asyncio deprecated explicit loop passing and made it rather cumbersome to work with. This means that the old approach of creating an implicit loop using asyncio.get_event_loop() with asyncio.run means that it'll always result in an error due to different event loops.

In order to facilitate these changes, breaking changes had to be made to the Client to allow it to work with asyncio.run.

Note that Python 3.10 removed the loop parameter, therefore discord.py is merely adapting to modern asyncio standards in its design.

Breaking Changes

While Client.run still works, accessing the Client.loop attribute will now result in an error if it's accessed outside of an async context. In order to do any sort of asynchronous initialisation, it is recommended to refactor your code out into a "main" function. For example:

# Before

bot = commands.Bot(...)

bot.loop.create_task(background_task())
bot.run('token')
# After
bot = commands.Bot(...)

async def main():
    async with bot:
        bot.loop.create_task(background_task())
        await bot.start('token')

asyncio.run(main())

While this is more lines of code to do the same thing, this gives you the greatest amount of flexibility in terms of asynchronous initialisation. If you desire something simpler, you can use the brand new Client.setup_hook in your subclass. For example:

class MyBot(commands.Bot):
    async def setup_hook(self):
        self.loop.create_task(background_task())

bot = MyBot()
bot.run('token')

Warning It is important to note that using wait_until_ready inside a setup_hook can cause a deadlock (i.e. your bot hangs).

Interactions with ext.tasks

Note that ext.tasks also requires it to start inside an asynchronous context now as well. This means that this code used to be valid but no longer is:

# Before
@tasks.loop(minutes=5)
async def my_task():
    ...

my_task.start()
bot.run('token')

In order to fix this, your choices are the same as mentioned above. Either move it into an asynchronous main function or use the new setup_hook. For example:

# After
@tasks.loop(minutes=5)
async def my_task():
    ...


async def main():
    async with bot:
        my_task.start()
        await bot.start('token')

asyncio.run(main())

Interaction with aiohttp

Creating an aiohttp.ClientSession inside __init__ or outside Client.run or asyncio.run will suffer from the same problems mentioned above. The fix for it is the same.

# Before
bot.session = aiohttp.ClientSession()
bot.run('token')

# After
async def main():
    async with aiohttp.ClientSession() as session:
        async with bot:
            bot.session = session
            await bot.start('token')

asyncio.run(main())

ext.commands Breaking Changes

Because of the removal of Client.loop in non-async contexts, some use cases involving asynchronous initialisation within cogs and extensions broke due to this change. As a result, cog and extension loading and unloading were made asynchronous to facilitate asynchronous initialisation.

For example:

# Before
class MyCog(commands.Cog):
    def __init__(self, bot):
        bot.loop.create_task(self.async_init())
        self.bot = bot

    async def async_init(self):
        ...

def setup(bot):
    bot.add_cog(MyCog(bot))

Can now be done doing:

# After
class MyCog(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    # New async cog_load special method is automatically called
    async def cog_load(self):
        ...

async def setup(bot):
    await bot.add_cog(MyCog(bot))

Note that to load and unload an extension you now need await. Putting it all together, you can get a complete program that is more or less like this:

from discord.ext import commands, tasks

class MyBot(commands.Bot):
    def __init__(self):
        super().__init__(command_prefix='$')
        self.initial_extensions = [
            'cogs.admin',
            'cogs.foo',
            'cogs.bar',
        ]

    async def setup_hook(self):
        self.background_task.start()
        self.session = aiohttp.ClientSession()
        for ext in self.initial_extensions:
            await self.load_extension(ext)

    async def close(self):
        await super().close()
        await self.session.close()

    @tasks.loop(minutes=10)
    async def background_task(self):
        print('Running background task...')

    async def on_ready(self):
        print('Ready!')

bot = MyBot()
bot.run('token')

Changes

  • Client.loop is now invalid outside of async contexts.
  • Client is now an asynchronous context manager.
  • [commands] Extensions and cogs are now fully async.
    • Add new Cog.cog_load async init
    • Change extension setup and teardown to be async.
    • Change Bot.load_extension, Bot.reload_extension, and Bot.remove_extension to be async.
    • Change Cog.cog_unload to be maybe async.
    • Change Bot.add_cog and Bot.remove_cog to be async.
@Trimatix
Copy link

Trimatix commented Mar 14, 2022

This is awesome, thanks Danny 🔥
I can already see heaps of uses for the new async cog load and bot start patterns for my projects

@Ykpauneu
Copy link

Nice

@CalebEevee
Copy link

Wow

@tilda
Copy link

tilda commented Mar 15, 2022

great to see cogs & extensions fully async!

@ByteEntity
Copy link

asynchronous programming!

@sergree
Copy link

sergree commented Mar 19, 2022

Great!

@Beaend
Copy link

Beaend commented Mar 27, 2022

Cool

@notteaproblem
Copy link

Thank you! Extremely helpful for my refactor!

@benjaminmesser
Copy link

benjaminmesser commented Nov 30, 2022

I followed these steps, but unfortunately I always just keep getting the error RuntimeError: asyncio.run() cannot be called from a running event loop on the asyncio.run(main()) line. Any suggestions? I tried the nest_asyncio hack, see github.com/erdewit/nest_asyncio, which fixes the error, but then just breaks other things. Would appreciate any help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment