This issue tracker has been migrated to GitHub, and is currently read-only.
For more information, see the GitHub FAQs in the Python's Developer Guide.

classification
Title: Make @atexit.register work for functions with arguments
Type: enhancement Stage:
Components: Versions:
process
Status: open Resolution:
Dependencies: Superseder:
Assigned To: Nosy List: quapka, serhiy.storchaka, terry.reedy
Priority: normal Keywords:

Created on 2021-12-11 17:27 by quapka, last changed 2022-04-11 14:59 by admin.

Messages (6)
msg408321 - (view) Author: quapka (quapka) Date: 2021-12-11 17:27
Hi folks!

Let me first present an example that motivated this issue. Imagine a script that builds Docker images and later starts them as Docker containers. To avoid having to stop the containers "manually" (in code and potentially forgot) I had an idea to register each container when started and stop each using atexit module. I chose to encapsulate this behavior inside a class. A much simplified example looks like this:

    import atexit

    class Program:
        # keep a class level list of started containers
        running_containers = []

        @atexit.register
        @classmethod
        def clean_up(cls, *args, **kwargs):
            for container in cls.running_containers:
                print(f'stopping {container}')

        def start_container(self, container):
            print(f'starting {container}')
            self.__class__.running_containers.append(container)

    prog = Program()
    a.start_container('container_A')
    a.start_container('container_B')

And I'd expect this to produce:

    starting container_A
    starting container_B
    stopping container_A
    stopping container_B

To me, this reads rather nicely: the Program.clean_up method can be called by the user, but if he forgets it will be handled for him using atexit. However, this code does not work. :) I've spent some time debugging and what follows are my observations:

1) If the order of decorators is @atexit.register and then @classmethod then the code throws 'TypeError: the first argument must be callable'. I believe it is because classmethod and staticmethod are descriptors without the __call__ method implemented. atexit.register does not check this and instead of func.__func__ (which resolves to Program.clean_up) gets func (a classmethod) which is not callable (https://github.com/python/cpython/blob/main/Modules/atexitmodule.c#L147).

2) If the order of decorators is swapped (@classmethod and @atexit.register) then the code throws "Error in atexit._run_exitfuncs:\nTypeError: clean_up() missing 1 required positional argument: 'cls'". From my limited understanding of CPython and atexitmodule.c I think what happens is that the @atexit.register returns (https://github.com/python/cpython/blob/main/Modules/atexitmodule.c#L180) the func without the args and kwargs (since this issue https://bugs.python.org/issue1597824).

3) However, if I step away from decorating using @atexit.register and instead use

    [...]
    atexit.register(Program.clean_up) # <-- register post definition
    prog = Program()
    a.start_container('container_A')
    a.start_container('container_B')

then the code works as expected and outputs:

    starting container_A
    starting container_B
    stopping container_A
    stopping container_B


To summarize, I don't like 3) as it puts the responsibility in a bit awkward place (good enough if I'm the only user, but I wonder about the more general library-like cases). My decorating skills are a bit dull now and it's my first time seriously looking into CPython internals - I've tried to encapsulate atexit.register in my custom decorator, to check whether that could be a workaround but overall was unsuccessful. In short, I'd say that in both 1) and 2) the cls arg is lost when atexit calls the function. I've tried to dig it up from the func passed to atexit.register

    def my_atexit_decorator(func, *args, **kwargs):
        cls = # some magic with under attrs and methods
        register.atexit(func, cls=cls, *args, **kwargs)
        [...]

, but failed (it also felt like a fragile approach).

I was not able to understand why @atexit.register does not work when the function's signature is not empty. Also, if fixable I'm happy to actually put the effort into fixing it myself (looks like a nice first CPython PR), but I'd like to have someone else's opinion before I start marching in the wrong direction. Also, let me know if you'd like more details or code/tests I've produced while debugging this.

Cheers!
msg408403 - (view) Author: quapka (quapka) Date: 2021-12-12 17:47
I'm adding the tests I've written for this issue. First, the tests that do pass already: https://github.com/quapka/cpython/commit/913055932be4be1c61ac8383615045f8bceee4e8

Secondly, the ones that I'd expect to pass as well, but fail atm:
https://github.com/quapka/cpython/commit/916968fcebe0266baebce7209ef6db25c091b604
msg408823 - (view) Author: Terry J. Reedy (terry.reedy) * (Python committer) Date: 2021-12-17 23:38
You might post on python-ideas list to get comments from other possible users.
msg408949 - (view) Author: quapka (quapka) Date: 2021-12-20 08:50
Hi @terry.reedy,
are you talking about this
https://mail.python.org/mailman3/lists/python-ideas.python.org/
mailing list? Thanks for the tip. However, I don't understand why this isn't just buggy/unexpected behavior, because the 3) example seems to work without any (observed) issues.
msg408968 - (view) Author: Terry J. Reedy (terry.reedy) * (Python committer) Date: 2021-12-20 17:50
Yes, that is the list.

Serhiy, can you comment on using atexit.register and classmethod decorators together?  Or suggest someone else?
msg408969 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2021-12-20 18:27
It cannot work this way.

atexit.register() as a function allows you to specify arguments which will be passed to the registered function, but if it is used as a decorator, only one argument (the function itself) is passed to atexit.register() (it is how decorators work). When used as a decorator it can only register functions without parameters.

On other hand, the function corresponding to a class method requires the class argument. There is no magic to determine it at the decoration time, because the class does not exist yet. It will be created only after the class definition body be executed.

It is possible to create a decorator which work the way you want. The simplest method perhaps to create a classmethod subclass with __set_name__() method. __set_name__() will be called after creating the class.

class mydecorator(classmethod):
    def __set_name__(self, type, name):
        atexit.register(self.__func__, type)

class Program:
    @mydecorator
    def clean_up(cls):
        ...
History
Date User Action Args
2022-04-11 14:59:53adminsetgithub: 90209
2021-12-20 18:27:46serhiy.storchakasetmessages: + msg408969
2021-12-20 17:50:26terry.reedysetnosy: + serhiy.storchaka
messages: + msg408968
2021-12-20 08:50:56quapkasetmessages: + msg408949
2021-12-17 23:38:01terry.reedysetnosy: + terry.reedy
messages: + msg408823
2021-12-12 17:47:07quapkasetmessages: + msg408403
2021-12-11 17:27:01quapkacreate