import json import os import sys from functools import wraps, partial from PyQt5.QtCore import QThreadPool, QTimer, Qt from utils.task import Task from utils.utilities import get_base_settings, SettingsClass, ProfileLoadError def threaded_cooldown(func): """ A decorator that makes it so the decorate function will run in a thread, but prevents the same function from being rerun for a given time. After give time, the last call not performed will be executed. Purpose of this is to ensure writing to disc does not happen all too often, avoid IO operations reducing GUI smoothness. A drawback is that if a user "queues" a save, but reloads the file before the last save, they will load a version that is not up to date. This is not a problem for Grabber, as the settings are only read on startup. However, it's a drawback that prevents a more general use. This decorator requires being used in an instance which has a threadpool instance. """ timer = QTimer() timer.setInterval(10000) timer.setSingleShot(True) timer.setTimerType(Qt.VeryCoarseTimer) def wrapper(self, *args, **kwargs): if not hasattr(self, 'threadpool'): raise AttributeError(f'{self.__class__.__name__} instance does not have a threadpool attribute.') if not hasattr(self, 'force_save'): raise AttributeError(f'{self.__class__.__name__} instance does not have a force_save attribute.') worker = Task(func, self, *args, **kwargs) if timer.receivers(timer.timeout): timer.disconnect() if self.force_save: timer.stop() self.threadpool.start(worker) self.threadpool.waitForDone() return if timer.isActive(): timer.timeout.connect(partial(self.threadpool.start, worker)) timer.start() return timer.start() self.threadpool.start(worker) return return wrapper def threaded(func): """ Gives a function to a Task object, and then gives it to a thraed for execuution, to avoid using the main loop. """ @wraps(func) def wrapper(self, *args, **kwargs): if not hasattr(self, 'threadpool'): raise AttributeError(f'{self.__class__.__name__} instance does not have a threadpool attribute.') worker = Task(func, self, *args, **kwargs) self.threadpool.start(worker) return return wrapper class FileHandler: """ A class to handle finding/loading/saving to files. So, IO operations. """ # TODO: Implement logging, since returned values from threaded functions are discarded. # Need to know if errors hanppen! def __init__(self, settings_path='settings.json', profiles_path='profiles.json'): self.profile_path = profiles_path self.settings_path = settings_path self.work_dir = os.getcwd().replace('\\', '/') self.force_save = False self.threadpool = QThreadPool() self.threadpool.setMaxThreadCount(1) def __repr__(self): return f'{__name__}(settings_path={self.settings_path}, profile_path={self.profile_path})' @staticmethod def find_file(relative_path, exist=True): """ Get absolute path to resource, works for dev and for PyInstaller """ try: # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS except AttributeError: base_path = os.path.abspath(".") path = os.path.join(base_path, relative_path).replace('\\', '/') if FileHandler.is_file(path) or not exist: return path else: return None @threaded_cooldown def save_settings(self, settings): try: with open(self.settings_path, 'w') as f: json.dump(settings, f, indent=4, sort_keys=True) return True except (OSError, IOError) as e: # TODO: Logging! return False @threaded_cooldown def save_profiles(self, profiles): try: with open(self.profile_path, 'w') as f: json.dump(profiles, f, indent=4, sort_keys=True) return True except (OSError, IOError) as e: # TODO: Logging! return False def load_settings(self, reset=False) -> SettingsClass: """ Reads settings, or writes them if absent, or if instructed to using reset. """ def get_file(path): """ """ if FileHandler.is_file(path): with open(path, 'r') as f: return json.load(f) else: return {} try: profiles = get_file(self.profile_path) except json.decoder.JSONDecodeError as e: raise ProfileLoadError(str(e)) if reset: return SettingsClass(get_base_settings(), profiles, self) else: settings = get_file(self.settings_path) if settings: return SettingsClass(settings, profiles, self) else: return self.load_settings(reset=True) @staticmethod def is_file(path): return os.path.isfile(path) and os.access(path, os.X_OK) def find_exe(self, program): """Used to find executables.""" # Possible Windows specific implementation local_path = os.path.join(self.work_dir, program) if FileHandler.is_file(local_path): # print(f'Returning existing isfile exe: {os.path.join(self.work_dir, program)}') return local_path for path in os.environ["PATH"].split(os.pathsep): path = path.strip('"') exe_file = os.path.join(path, program) if FileHandler.is_file(exe_file): # print(f'Returning existing exe: {os.path.abspath(exe_file)}') return os.path.abspath(exe_file) return None @staticmethod def read_textfile(path): if FileHandler.is_file(path): try: with open(path, 'r') as f: content = f.read() return content except (OSError, IOError) as e: return None else: return None @threaded def write_textfile(self, path, content): # TODO: Warn user on error. Need smart simple method to send message from threadpool. if FileHandler.is_file(path): try: with open(path, 'w') as f: f.write(content) return True except (OSError, IOError) as e: # TODO: Logging error return False else: # TODO: Logging error return False