pythonbeeware

How can I update a Toga progress bar after each download?


When the download button is pressed it is supposed to download a set of files from a specified url. After each download I update the progress bar accordingly, but the bar only updates after all the files have been downloaded. The UI is being blocked until all downloads are complete. I am using Beeware's Toga for the UI. I have tried using async and await but the download function is synchronous so it doesn't work. Below is the code:

"""
Downloader
"""
import pathlib, os
import toga
from toga.style import Pack
from toga.style.pack import COLUMN, ROW, LEFT, CENTER, TOP
from downloader.my_libs.downloader import downloader


WHITE = '#ffffff'
PRIMARY_COLOR = '#000000'
SECONDARY_COLOR = '#fbfbfb'
ACCENT_COLOR = '#80ff80'
BACKGROUND_COLOR = '#ffffff'


class Downloader(toga.App):
    def __init__(self, formal_name=None, app_id=None, app_name=None, id=None, icon=None, author=None, version=None, home_page=None, description=None, startup=None, windows=None, on_exit=None, factory=None):
        super().__init__(formal_name, app_id, app_name, id, icon, author, version, home_page, description, startup, windows, on_exit, factory)
        self.resource_folder = pathlib.Path(__file__).joinpath('../resources').resolve()
        self.url_filename = self.resource_folder.joinpath('url.txt')
        self.output_path = pathlib.Path(__file__).joinpath('../../../../files').resolve()
        self.downloader = Downloader(url='', output_path=self.output_path)
        self.number_of_items_downloaded = 0
        self.number_of_items_to_download = 0

    def startup(self):
        main_box = toga.Box(style=Pack(background_color=BACKGROUND_COLOR))
        box = toga.Box(style=Pack(background_color=BACKGROUND_COLOR))
        url_box = toga.Box()

        title_label = toga.Label(text='Downloader', style=Pack(text_align=CENTER, font_size=20, flex=1, background_color=BACKGROUND_COLOR, color=PRIMARY_COLOR))
        self.url_input = toga.TextInput(style=Pack(flex=4, font_size=12, background_color=SECONDARY_COLOR, color=PRIMARY_COLOR))
        self.download_button = toga.Button('Download', on_press=self.on_download, style=Pack(flex=1, font_size=12, padding_top=5, background_color=ACCENT_COLOR, color=PRIMARY_COLOR))
        self.info_box = toga.MultilineTextInput(readonly=True, style=Pack(flex=1, padding_top=10, font_size=12, background_color=SECONDARY_COLOR, color=PRIMARY_COLOR))
        self.progress_bar = toga.ProgressBar(max=100, value=0, style=Pack(padding_top=10, background_color=SECONDARY_COLOR, color=ACCENT_COLOR))

        box.add(title_label)
        url_box.add(self.url_input)
        box.add(url_box)
        box.add(self.download_button)
        box.add(self.info_box)
        box.add(self.progress_bar)
        main_box.add(box)

        box.style.update(direction=COLUMN, padding=50, flex=1)
        url_box.style.update(direction=ROW, flex=1, padding_top=5)

        self.main_window = toga.MainWindow(title=self.formal_name)
        self.main_window.content = main_box
        self.main_window.show()

    def on_download(self, widget):
        self.progress_bar.start()
        self.download_files()
        self.progress_bar.stop()
    
    def download_files(self):
        self.number_of_items_to_download = len(self.downloader.urls)

        for url in self.downloader.urls:
            try:
                self.download_file(url)
                self.update_progress()
            except Exception as exception:
                print('Could not download from:', url)
                print(exception, '\n\n')
                self.info_box.value = self.info_box.value + f'\nCould not download from: {url}' + f'exception\n\n'

    def update_progress(self):
        self.progress_bar.value = (self.number_of_items_downloaded / self.number_of_items_to_download) * 100

    def download_file(self, url):
        title, filepath = self.downloader.download(url)
        self.info_box.value = self.info_box.value + f'Downloaded {title} \nTo {filepath}\n\n'
        self.number_of_items_downloaded += 1


def main():
    return Downloader()

Solution

  • The main principles to remember in most UI frameworks are:

    Here's a solution that follows both of those rules. I haven't tested it, but it should be pretty close:

        def on_download(self, widget):
            # As of Toga 0.4, self.loop is pre-set by Toga itself, 
            # so the next line should be removed.
            self.loop = asyncio.get_event_loop()
            threading.Thread(target=self.download_files).start()
        
        def download_files(self):
            self.number_of_items_to_download = len(self.downloader.urls)
    
            for url in self.downloader.urls:
                try:
                    self.download_file(url)
                except Exception as exception:
                    print('Could not download from:', url)
                    print(exception, '\n\n')
                    # TODO: use call_soon_threadsafe to update the UI
                    # self.info_box.value = self.info_box.value + f'\nCould not download from: {url}' + f'exception\n\n'
    
        def update_progress(self, title, filepath):
            self.info_box.value = self.info_box.value + f'Downloaded {title} \nTo {filepath}\n\n'
            self.progress_bar.value = (self.number_of_items_downloaded / self.number_of_items_to_download) * 100
    
        def download_file(self, url):
            title, filepath = self.downloader.download(url)
            self.number_of_items_downloaded += 1
            self.loop.call_soon_threadsafe(self.update_progress, title, filepath)
    

    There's no need to join the thread. If you want to update the UI when the download is complete, just do that with another call to call_soon_threadsafe.

    Even better, you could use a higher-level API like run_in_executor:

        # Must add `async`!
        async def on_download(self, widget):  
            loop = asyncio.get_event_loop()
            self.number_of_items_to_download = len(self.downloader.urls)
    
            for url in self.downloader.urls:
                try:
                    title, filepath = await loop.run_in_executor(
                        None, self.downloader.download, url 
                    )
                    self.info_box.value = self.info_box.value + f'Downloaded {title} \nTo {filepath}\n\n'
                    self.number_of_items_downloaded += 1
                    self.progress_bar.value = (self.number_of_items_downloaded / self.number_of_items_to_download) * 100
                except Exception as exception:
                    print('Could not download from:', url)
                    print(exception, '\n\n')
                    self.info_box.value = self.info_box.value + f'\nCould not download from: {url}' + f'exception\n\n'