Why is this kivy error not caught, after adding widget created with Builder.load_string()?

Viewed 1445

Summary of what goes wrong

I'm trying to add a root widget to an existing .kv app, where said arbitrary root widget is created by a kivy.lang.Builder.load_string method. This will work fine if the kivy string supplied to the Builder represents valid and legal .kv code. Expectedly, it will fail otherwise.

To account for this, I've added a try - except block, in the hopes of catching any error that may have caused the failure of adding appropriate kivy widgets. The corresponding Exception is then used inside a popup-message, After which the invalid widgets are ultimately not supposed to be added.

For some inputs this works as expected (shows popup message if error). However, for specific string inputs, the app crashes without catching the responsible errors. Now I'm wondering why these errors were not caught, and how to catch them correctly. See beneath for the exact code.

My Application

My app consists of one .py and one .kv file1, as below (simplified):

# main.kv
ScreenManager:
    Screen:
        name: 'string_screen'
        BoxLayout:
            orientation: 'vertical'
            TextInput:
                id: code_text
                text: app.text
            Button:
                text: 'call'
                on_release: app.call()

    Screen:
        name: 'called_screen'
        BoxLayout:
            id: render_layout

<Button>:
    size_hint: 0.5, None
    height: '1.2cm'

<MsgPopup>:
    size_hint: .75, .6
    title: "Attention"

    BoxLayout:
        orientation: 'vertical'
        padding: 10
        spacing: 20
        Label:
            id: message_label
            size_hint_y: 0.4
            text: "Label"
        Button:
            text: 'Dismiss'
            size_hint_y: 0.4
            on_press: root.dismiss()

And the python file:

# main.py
from kivy.app import App
from kivy.properties import StringProperty
from kivy.lang import Builder
from kivy.uix.popup import Popup


class MainApp(App):
    text = StringProperty()
    kv = None
    def call(self):
        kv_text = self.root.ids['code_text'].text
        try:
            self.kv = Builder.load_string(kv_text)
            print(self.kv)
            self.root.ids['render_layout'].clear_widgets()
            print('cleared')
            self.root.ids['render_layout'].add_widget(self.kv)
            print('added')
            self.root.current = 'called_screen'
            self.root.transition.direction = 'left'
            print('swiped')
        except Exception as e:
            popup = MsgPopup(e)
            popup.open()


class MsgPopup(Popup):
    def __init__(self, msg):
        super().__init__()
        self.ids.message_label.text = str(msg)


if __name__ == '__main__':
    MainApp().run()

1 My actually app consists of some additional and more elaborated code, but this simplified version of it is sufficient for reproducing the undesired behavior.

As you can see in the code, the app consists of two screens. The main element in the first is the TextInput from which the second one is created. The below two images demonstrate when there is no error.

Screen1 Screen2

Below is an example of correct behavior, when the text input contains anything that produces an error:

enter image description here

Unexpected Behavior

This last image shows correctly a popup message. However, when I enter the following input in the TextInput field for example:

FloatLayout:
    Label:
        text: "Hello World"
        pos_hint: 0.5, 0.7

Which is an error in the pos_hint value argument. Then the app crashes once I press the call Button. And Instead of the expected popup message, I get an actual stack trace!

<kivy.uix.floatlayout.FloatLayout object at 0x0000016F7F0FC800>
cleared
   File "C:/Users/ajdin/.PyCharmCE2019.1/config/scratches/scratch_1.py", line 34, in <module>
added
swiped
     MainApp().run()
   File "C:\Users\ajdin\Anaconda3\lib\site-packages\kivy\app.py", line 826, in run
     runTouchApp()
   File "C:\Users\ajdin\Anaconda3\lib\site-packages\kivy\base.py", line 502, in runTouchApp
     EventLoop.window.mainloop()
   File "C:\Users\ajdin\Anaconda3\lib\site-packages\kivy\core\window\window_sdl2.py", line 727, in mainloop
     self._mainloop()
   File "C:\Users\ajdin\Anaconda3\lib\site-packages\kivy\core\window\window_sdl2.py", line 460, in _mainloop
     EventLoop.idle()
   File "C:\Users\ajdin\Anaconda3\lib\site-packages\kivy\base.py", line 346, in idle
     Clock.tick_draw()
   File "C:\Users\ajdin\Anaconda3\lib\site-packages\kivy\clock.py", line 588, in tick_draw
     self._process_events_before_frame()
   File "kivy\_clock.pyx", line 427, in kivy._clock.CyClockBase._process_events_before_frame
   File "kivy\_clock.pyx", line 467, in kivy._clock.CyClockBase._process_events_before_frame
   File "kivy\_clock.pyx", line 465, in kivy._clock.CyClockBase._process_events_before_frame
   File "kivy\_clock.pyx", line 167, in kivy._clock.ClockEvent.tick
   File "C:\Users\ajdin\Anaconda3\lib\site-packages\kivy\uix\floatlayout.py", line 116, in do_layout
     for key, value in c.pos_hint.items():
 AttributeError: 'tuple' object has no attribute 'items'

Process finished with exit code 1

My expected output here would be similar to before: The above shown stack trace message shown in a popup window, without the application crashing!. I expect that because of the way I handle the exception in the button callback:

kv_text = self.root.ids['code_text'].text
try:
    self.kv = Builder.load_string(kv_text)
    print(self.kv)
    self.root.ids['called_screen'].clear_widgets()
    print('cleared')
    self.root.ids['called_screen'].add_widget(self.kv)
    print('added')
    self.root.current = 'called_screen'
    self.root.transition.direction = 'left'
    print('swiped')
except Exception as e:
    popup = MsgPopup(e)
    popup.open()

So if there were an error within the load_string method, I expect to catch it. Otherwise if it passes that somehow, I expect to catch an error in the add_widget method. However, from the stack trace above it seems that it passes all these statements succesfully with the erroneous text input given!. You can see this from the printed outputs in the stack trace:

...
<kivy.uix.floatlayout.FloatLayout object at 0x0000016F7F0FC800>
cleared
   File "C:/Users/ajdin/.PyCharmCE2019.1/config/scratches/scratch_1.py", line 34, in <module>
added
swiped
...

It prints all the statements in the try block, signaling that it passed through it without any errors, right?

Question

So if the above thrown error was not caught, what caused it, and how/where do I catch it properly, such that the app ends up with the intended behavior (An error pop up message at most)?

1 Answers

I believe the Exception is thrown in the mainloop, after your call() method completes. Normally, GUI updates only happen on the main thread, and thus must wait until your code (which is running on the main thread) completes. You can still catch those Exceptions using the Kivy ExceptionHandler by adding the following code to your Python.:

from kivy.base import ExceptionHandler, ExceptionManager

class E(ExceptionHandler):
    def handle_exception(self, inst):
        app = App.get_running_app()
        if app.scheduled_switch is not None:
            app.scheduled_switch.cancel()  # cancel the scheduled switch
            app.scheduled_switch = None
        if app.Exception_counter == 0:
            popup = MsgPopup(inst)
            popup.open()
        app.Exception_counter += 1
        return ExceptionManager.PASS

ExceptionManager.add_handler(E())

The above code also cancels a possibly scheduled Screen switch.

Then, to limit the Popup to just appear once per call() invocation, modify your App to include an Exception_counter. Also, to prevent switching to the called Screen, the modified code uses Clock to schedule the switch (which may be cancelled by the ExceptionHandler):

class MainApp(App):
    def __init__(self, **kwargs):
        self.Exception_counter = 0
        self.scheduled_switch = None
        super(MainApp, self).__init__(**kwargs)

    def call(self):
        self.Exception_counter = 0
        kv_text = self.root.ids['code_text'].text
        try:
            self.kv = Builder.load_string(kv_text)
            print(self.kv)
            self.root.ids['render_layout'].clear_widgets()
            print('cleared')
            self.root.ids['render_layout'].add_widget(self.kv)
            print('added')

            # schedule switch to 'called' screen
            self.scheduled_switch = Clock.schedule_once(self.switch_to_called_screen, 0.25)
        except Exception as e:
            popup = MsgPopup(e)
            popup.open()

    def switch_to_called_screen(self, dt):
        self.root.current = 'called_screen'
        self.root.transition.direction = 'left'
        print('swiped')
        self.scheduled_switch = None
Related