Search code examples
pythontkinterpython-imaging-librarypytest

How to make sure pytest properly close PIL.tkImage object


I've got pretty big tkinter GUI. There are some matplotlib charts and there is a .png scheme which should be constantly displayed in a frame. As i learn, there have to be a reference to PIL.TkImage object, to keep object alive after closing construtor object.

import tkinter as tk
from tkinter import ttk 
from PIL import ImageTk, Image

class View(tk.Tk):
    def __init__(self)
        ...
        self.image_cons()
        # Prevents keeping unfinished processes when quiting app by 'X' button,
        # caused by matplotlib
        self.protocol("WM_DELETE_WINDOW", self.quit)
    ...
    def image_cons(self):
        # there are several Frames in between
        self.scheme = Image.open("./images/scheme.png")
        self.scheme.thumbnail((420, 610))

        self.img = ImageTk.PhotoImage(self.scheme)
        label = ttk.Label(self.drawframe, image=self.img)
        label.pack(expand=True, fill=tk.Y)

    def quit(self):
        # Makes sure matplotlib plots are close with closing tkinter
        self.destroy()
        tk.Tk.quit(self)

And at this moment it works great. I can run app (thru Pycharm run or by command line) again and again and nothings bother. But.

I have a few pytest tests, where are created View instances, (each test one). After adding quite a lot lines of code, including the scheme, thhose tests started return failure with message (the number is increasing):

E       _tkinter.TclError: image "pyimage49" doesn't exist

So i did the search. This message is showing up in different situations, but what I understood is that in most of cases, previous application run wasn't close image file properly. And this I probably should figure out before - I intentionally made this self.img refference to prevent destroying image before application ends. And when I took one test into with statement

def test_A(self)
    with view_module.View(self.VARIABLES, self.DEFAULT_VALUES) as view:
        test_content()

def test_B(self)
    view = view_module.View(self.VARIABLES, self.DEFAULT_VALUES)
    ...

and add these methods to View class:

def __enter__(self):
    return self

def __exit__(self, exc_type, exc_val, exc_tb):
    self.quit()

it confirmes my conlcusions. Before using with statement, test_A was passing, test_B was first test that fails with error. After using with, both were passing, and first failure showed further - in next test instantiating View.

I thought that adding

    def __del__(self):
        self.img.destroy()
        self.scheme.destroy()
        self.destroy()
        tk.Tk.__del__(self)

close the case. But it seems this isnt't working - errors still showing up. Is there any way of making sure, that destroy() method will be triggered when pytest finish test with View instance? I'm assuming writing with statement in every test using View is not the pythonic way of dealing with this.

Edit: Tests are configurated as in here:

class TestView:
    def test_A(self)
        with view_module.View(self.VARIABLES, self.DEFAULT_VALUES) as view:
            test_content()

    def test_B(self)
        view = view_module.View(self.VARIABLES, self.DEFAULT_VALUES)
        ...

    def test_C(self)
        view = view_module.View(self.VARIABLES, self.DEFAULT_VALUES)
        ...

Is this causing, what @jasonharper suggested in comment? Is that because pytest holds reference to each view variable through all tests, and garbage collector doesn't delete it? What can I do with it, instead of write every test with with statement? Is there any configuration of pytest which i.e. overwrite each view?


Solution

  • Is this causing, what @jasonharper suggested in comment?

    Yes, this is the most probable cause.

    Is that because pytest holds reference to each view variable through all tests, and garbage collector doesn't delete it?

    No. This is something different.

    As mentioned in this answer, when multiple instances of tk.Tk() are created, tkinter stores first instance of tk.Tk() in tk._default_root, until destory().

    Now, when ImageTk.PhotoImage() is called without parent/master, gets default parent, i.e. tk._default_root. Please note that the tk._default_root is still your first instance of tk.Tk(). From the tkinter point of view any object created on one root (tk.Tk()) can not be access from the other root.

    Hence, the next test case which is calling tk.Tk(), is not getting access of the image and the error is thrown.

    What can I do with it, instead of write every test with with statement? Is there any configuration of pytest which i.e. overwrite each view?

    There are two options available. It is recommended to use both to avoid any such issues.

    Option 1: Add master to the ImageTk.PhotoImage()

    class View(tk.Tk):

       ...
       ...
       ...
    
       def image_cons(self):
           ...
           ...
           self.img = ImageTk.PhotoImage(self.scheme, master=self)
           ...
           ...
    
    Option 2: call view.destroy() at the end of each the test case
    class TestView:
        def test_A(self)
            view = view_module.View(self.VARIABLES, self.DEFAULT_VALUES)
            ...
            ...
            view.destroy()
    
        def test_B(self)
            view = view_module.View(self.VARIABLES, self.DEFAULT_VALUES)
            ...
            ...
            view.destroy()
    
        def test_C(self)
            view = view_module.View(self.VARIABLES, self.DEFAULT_VALUES)
            ...
            ...
            view.destroy()
    
    Bonus

    In order to hold reference of the image, the image variable can be directly added in the object itself. In this way creation of multiple instance level variable can be avoided. And life of that image will be tied to the live of its owning object.

        img = ImageTk.PhotoImage(self.scheme)
        label = ttk.Label(self.drawframe, image=self.img)
        label.img = img
        label.pack(expand=True, fill=tk.Y)