How to await a async call in a descriptor's __get__?

48 Views Asked by At

I want to write a lazyloadable property. It could load data when first __get__(), But __get__() couldn't set as async. Is there any way to await obj.load() finished then return the getter's return.

class LazyLoadableProperty:
    def __get__(self, obj, owner_class):
        if obj is None:
            return self
        if not self.__loaded:
            # task = asyncio.create_task(obj.load())
            # while True:
            #     break when task is finished
            self.__loaded = True
        return self.__get(obj) # __get() is a costume getter set when init.
    ...


class A(LazyLoadable):
    _name: str
    def __init__():
        self._name = ""

    async def load(): # LazyLoadable is a abc and LazyLoadable.load is an async func.
        # load from http
        asyncio.sleep(1)
        self._name = "Jax"

    @LazyLoadableProperty
    def name(self):
        return self._name


async def main():
    a = A()
    print(a.name)

asyncio.run(main())
  1. If I directly call: RuntimeWarning: coroutine 'A.load' was never awaited
obj.load() 
  1. If use run_until_complete: RuntimeError: This event loop is already running
asyncio.get_event_loop().run_until_complete(obj.load())

Some other ways may modified too much existing code I don't want to use:

  1. __get__() returns an Awaitable and await when await a.name.
  2. LazyLoadable is a abc. It's hard to change all <subclass>.load() to sync func.
1

There are 1 best solutions below

0
Paul Cornelius On

You're missing an important concept here. When your script encounters this line:

print(a.name)

the value of a.name is not immediately available. That's the whole point of what you're trying to do. Consider: how is the value going to become available while main waits for it? In your comment you say it's from doing an HTTP call. But to do an HTTP call, you must execute code. And in a single-threaded asyncio program, the only place where such code can execute is in another Task, outside of the main function. And to yield control to another Task, you need to execute an await expression. In the code you present there is no await statement at all, so your approach has no chance of working.

Your main function must look something like this:

async def main():
    a = A()
    print(await a.name)

The await expression pauses main until another Task computes the value of a.name. This may not be what you wanted, but if the value of a.name is not available yet, there is really no other logical possibility.

As you can see from this little code snippet, the expression a.name will be an awaitable object. It can also be a property - there is no rule against that. The following code, when you run it, delays for one second and then prints "Jax."

import asyncio

class A:
    _name: str
    def __init__(self):
        self._name = ""

    async def load_name(self):
        # load from http
        if not self._name:
            await asyncio.sleep(1)
            self._name = "Jax"
        return self._name

    @property
    async def name(self):
        return await self.load_name()

async def main():
    a = A()
    print(await a.name)

asyncio.run(main())

This script does exactly the same thing.

import asyncio

class A:
    _name: str
    def __init__(self):
        self._name = ""

    async def load_name(self):
        # load from http
        if not self._name:
            await asyncio.sleep(1)
            self._name = "Jax"
        return self._name

    @property
    def name(self):
        return self.load_name()


async def main():
    a = A()
    print(await a.name)

asyncio.run(main())

The second version contains fewer keystrokes. But I personally prefer the first version, which makes it more clear that the property name is an awaitable object.

There is no need to create a special class or invent a special type of property.

I also fixed your omission of the self argument in a couple of places and added the necesary await in front of asyncio.sleep.