Search code examples
python-3.xkivyconfigpyinstallerkivymd

Add settings config file to exe when packaging kivy app


I am making an app that uses a .ini config file to store settings values. With PyInstaller, I am trying to create a standalone -exe file that includes the config file (to be honest, I am not sure if this is possible), or at least stores it somewhere else. Whenever I change a setting on the app, a config file is created in the same location as the .exe file.

The App running before I change a setting. Notice that no .ini config file is made. The App running before I change a setting. Notice that no .ini config file is made.

The App running afterI change a setting. Notice that an .ini config file is made. The App running afterI change a setting. Notice that an .ini config file is made.

Code that runs the app (main.py):

class MangaDownloader(MDApp):
    # This property is declared here as 'global' property, it will contain any found manga related to user input
    manga_data = DictProperty(None)
    # This property will be a reference to the selected manga site from which the app will download from
    downloader = StringProperty(None)
    # This property will check to see if a manga is being downloaded; used to show a popup if the download path is changed
    is_a_manga_being_downloaded = BooleanProperty(False)
    # The folder where all manga will be downloaded to, AKA: the manga root
    manga_root_dir = StringProperty(None)
    # The folders which will contain manga in english or Japanese
    english_manga_dir, japanese_manga_dir = StringProperty(None), StringProperty(None)
    
    def __init__(self):
        super().__init__()
        self.manga_root_dir = resource_path(os.path.join(self.user_data_dir, "Manga"))

        self.default_settings_vals = {
            'theme_mode':'Dark',
            'color_scheme':'Pink',
            'default_downloader': "rawdevart",
            'download_path': resource_path(self.manga_root_dir),
            'manga_reading_direction': 'Swipe Horizontally', # Defaults to reading horizontally (swiping)
            'manga_swiping_direction':"Right to Left (English style)" # Defaults to English style: left to right
        }
        
    # Build the settings and sets their default values
    def build_config(self, config):
        config.setdefaults('Settings', self.default_settings_vals)
        
    def build_settings(self, settings):
        settings.add_json_panel('Manga Downloader Settings', self.config, data=AppSettings.json_settings)
        
    # Method that builds all the GUI elements    
    def build(self):
        self.title = "Manga Downloader"
        self.dialog = None # Used to get user confirmation

        # Settings
        self.settings_cls = AppSettings.ScrollableSettings # Section is called 'Settings'
        self.use_kivy_settings = False

        # Customizable Settings 
        self.theme_cls.theme_style = self.config.get("Settings", "theme_mode") # Dark or Light
        self.theme_cls.primary_palette = self.config.get("Settings", "color_scheme")
        self.downloader = self.config.get("Settings", "default_downloader") # The default manga downloading site
        
        # The path where all manga will be downloaded to (default is manga root)
        # If the client changes the download path while a manga is being downloaded an error will pop up
        self.download_path = resource_path(self.config.get("Settings", "download_path")) 
        
        self.manga_reading_direction = self.config.get("Settings", "manga_reading_direction")
        self.manga_swiping_direction = self.config.get("Settings", "manga_swiping_direction")
        
        # Manga Root Directory
        # If the user has changed the default download path (AKA: the manga root path) then set the manga root to the newly set path
        self.manga_root_dir = self.download_path if self.manga_root_dir != self.download_path else self.manga_root_dir
        
        self.english_manga_dir = resource_path(os.path.join(self.manga_root_dir, "English Manga"))
        self.japanese_manga_dir = resource_path(os.path.join(self.manga_root_dir, "Raw Japanese Manga"))
        create_root_dir(self.manga_root_dir)
        create_language_dirs([self.english_manga_dir,self.japanese_manga_dir])
        
       
        # Screen related
        self.screen_manager = ScreenManager()

        self.landing_page = LandingPage(self)
        screen = MangaScreen(name="Landing Page")
        screen.add_widget(self.landing_page)
        self.screen_manager.add_widget(screen)

        return self.screen_manager

    """Below this comment there are a couple more functions, but they are irelevant to this question"""


    # This method can handle any changes made to the settings, it also changes them when they are changed
    def on_config_change(self, config, section, key, value):
        print(config, section, key, value, "config change event fired")

        """
        This func exists because if the client changes the download path while downloading a manga
        then not all the chapters will be downloaded to the new path
        """ 
        def change_download_path(value=value):
            #root_src, new_dst = os.path.join(self.download_path), os.path.join(value)
            root_src, new_dst = resource_path(self.download_path), resource_path(value)
            print("src: ", root_src, "dst: ", new_dst)
            try:
                # Recursive function to move the english and Japanese manga containing folders to the new destination
                move_manga_root(root_src,new_dst)
                print(f"Download Path was successfully moved to {new_dst}")
                self.manga_root_dir = self.download_path = resource_path(self.config.get("Settings", "download_path"))
                toast(f"Manga Download Path has been changed to {self.manga_root_dir}")
            
            except PermissionError:
                toast("Permission Error occurred; You maybe don't have access")
            except:
                if root_src != new_dst:
                    toast("Unknown Error: If you have moved any folders/files yourself, they will appear in the new path")

        # A callback function for a confirmation dialog
        def reset_settings_config(inst):
            if isinstance(self.dialog, MDDialog):
                self.dialog.dismiss(force=True)
                self.dialog = None
            config.setall("Settings",self.default_settings_vals)
            config.write()
            change_download_path(resource_path(self.default_settings_vals.get("download_path")))
            self.close_settings()
            self.destroy_settings()
            self.open_settings()

        # This section will reset all settings to their default values
        if key == "configchangebuttons":
            self.dialog = None
            if not self.dialog:
                self.dialog = ConfirmationDialog(
                    title= "Reset to Factory Settings Confirmation: ",
                    text= "Warning: This will remove all current settings!\nAny Downloaded Manga will be moved to the default download folder!",
                    proceed_button_callback = reset_settings_config)
            self.dialog.open()
            
        # Moves the root/download folder to the new path
        if key == "download_path" and os.path.isdir(resource_path(os.path.join(value))):
            if self.is_a_manga_being_downloaded:
                # I have given up on this; it is not a part of my requirements
                toast("Warning: The download path has been changed while a manga is being downloaded. All new chapters will be downloaded to the new path")
            change_download_path()
        
        self.theme_cls.theme_style = self.config.get("Settings", "theme_mode")
        self.theme_cls.primary_palette = self.config.get("Settings", "color_scheme")
        #self.pc_download_path = self.config.get("Settings", "PCDownloadPath")
        #self.android_download_path = self.config.get("Settings", "AndroidDownloadPath")
        #self.download_path = self.config.get("Settings", "download_path") 
        self.downloader = self.config.get("Settings", "default_downloader")

        if key == "manga_swiping_direction": self.manga_swiping_direction = self.config.get("Settings", "manga_swiping_direction")

        if key == "manga_reading_direction": self.manga_reading_direction = self.config.get("Settings", "manga_reading_direction")

    
if __name__ == "__main__":
    if hasattr(sys, '_MEIPASS'):
        resource_add_path(os.path.join(sys._MEIPASS))

    if getattr(sys, 'frozen', False):
        # this is a Pyinstaller bundle
        resource_add_path(sys._MEIPASS)
        resource_add_path(os.path.join(sys._MEIPASS, 'DATA'))
    MangaDownloader().run()
  

Here is the code I used to create my settings

class AppSettings:    
    json_settings = json.dumps([
        {'type': 'title', 'title': 'Color Scheme and Theme Settings'},

        {'type': 'scrolloptions',
        'title': 'Theme',
        'desc': 'Dark theme or Light theme',
        'section': 'Settings',
        'key': 'theme_mode',
        'options': ['Dark', 'Light']
        },

        {'type': 'scrolloptions',
        'title': 'Color scheme',
        'desc': 'This settings affects the color of: Text, borders... but not the theme(light or dark)',
        'section': 'Settings',
        'key': 'color_scheme',
        'options': ['Red', 'Pink', 'Purple', 'DeepPurple', 'Indigo', 'Blue', 'LightBlue', 'Cyan', 'Teal', 'Green', 'LightGreen', 'Lime', 'Yellow', 'Amber', 'Orange', 'DeepOrange', 'Brown', 'Gray', 'BlueGray']
        },

        {'type': 'title','title': 'Download Settings'},

        {'type': 'scrolloptions',
        'title': 'Default Manga Site',
        'desc': 'The default site that will be used to download manga',
        'section': 'Settings',
        'key': 'default_downloader',
        'options': ["manganelo", "kissmanga", "rawdevart", "senmanga"]
        },

        {'type': 'path',
        'title': 'Download Folder Path',
        'desc': "All downloaded manga will be found in this folder. It will have 2 sub folders for English and Japanese manga",
        'section': 'Settings',
        'key': 'download_path'
        },

        {'type': 'title','title': 'Manga Reader Settings'},

        {'type': 'scrolloptions',
        'title': 'Manga Reading Direction',
        'desc': 'Turn on to scroll vertically while reading. Turn off to swipe horizontally for reading',
        'section': 'Settings',
        'key': 'manga_reading_direction',
        'options' : ["Scroll vertically", "Swipe Horizontally"],
        },

        {'type': 'scrolloptions',
        'title': 'Manga Reading Swipe Direction for page turning',
        'desc': 'The Japanese way of reading manga is: Left to Right. The English way is: Right to Left.',
        'section': 'Settings',
        'key': 'manga_swiping_direction',
        'options':["Left to Right (Japanese style)", "Right to Left (English style)"],
        },

        {'type':'title', 'title':'Misc.'},
        
        {"type": "buttons",
        "title": "Reset Settings",
        "desc": "Reset the settings to their default values",
        "section": "Settings",
        "key": "configchangebuttons",
        "buttons":[{"title":"Reset Settings","id":"reset_settings_btn"}]
        },
    ])
    
    class SettingScrollOptions(SettingOptions):
        def _create_popup(self, instance):
            # global oORCA
            # create the popup
            content = GridLayout(cols=1, spacing='5dp')
            scrollview = ScrollView(do_scroll_x=False)
            scrollcontent = GridLayout(cols=1,  spacing='5dp', size_hint=(None, None))
            scrollcontent.bind(minimum_height=scrollcontent.setter('height'))
            self.popup = popup = Popup(content=content, title=self.title, size_hint=(0.5, 0.9),  auto_dismiss=False)
            # we need to open the popup first to get the metrics
            popup.open()
            # Add some space on top
            content.add_widget(Widget(size_hint_y=None, height=dp(2)))
            # add all the options
            uid = str(self.uid)
            for option in self.options:
                state = 'down' if option == self.value else 'normal'
                btn = ToggleButton(text=option, state=state, group=uid, size=(popup.width, dp(55)), size_hint=(None, None), on_release=self._set_option)
                scrollcontent.add_widget(btn)

            # finally, add a cancel button to return on the previous panel
            scrollview.add_widget(scrollcontent)
            content.add_widget(scrollview)
            content.add_widget(SettingSpacer())
            # btn = Button(text='Cancel', size=((oORCA.iAppWidth/2)-sp(25), dp(50)),size_hint=(None, None))
            btn = Button(text='Cancel', size=(popup.width, dp(50)), size_hint=(0.9, None), on_release=popup.dismiss)
            content.add_widget(btn)

    class SettingButtons(SettingItem):
        def __init__(self, **kwargs):
            # For Python3 compatibility we need to drop the buttons keyword when calling super.
            kw = kwargs.copy()
            kw.pop('buttons', None)
            super(SettingItem, self).__init__(**kw)
            
            for aButton in kwargs["buttons"]:
                oButton=Button(text=aButton['title'], font_size= '15sp', on_release=self.on_button_press)
                oButton.ID=aButton['id']
                self.add_widget(oButton)
        
        def set_value(self, section, key, value):
            # set_value normally reads the configparser values and runs on an error
            # to do nothing here
            return
        
        def on_button_press(self,instance):
            self.panel.settings.dispatch('on_config_change',self.panel.config, self.section, self.key, instance.ID)
    
    class ScrollableSettings(SettingsWithSidebar):
        def __init__(self, *args, **kwargs):
            super().__init__(**kwargs)
            self.content_panel = self.interface.children[0]
            self.content_panel.bar_width = "10dp"
            self.register_type('scrolloptions', AppSettings.SettingScrollOptions)
            self.register_type('buttons', AppSettings.SettingButtons)

Solution

  • I was able to figure it out. I simply needed to add a method called get_application_config(self) to the class with my build() method. I have included a snippet to show where to put it.

    # Build the settings and sets their default values
    def build_config(self, config):
        config.setdefaults('Settings', self.default_settings_vals)
        
    def get_application_config(self):
        # This defines the path and name where the ini file is located
        #return str(os.path.join(os.path.expanduser('~'), 'mangadownloader.ini'))
        return str(os.path.join(self.user_data_dir, 'mangadownloader.ini'))
     
    def build_settings(self, settings):
        settings.add_json_panel('Manga Downloader Settings', self.config, data=AppSettings.json_settings)